diff --git a/crates/pixi_core/src/workspace/mod.rs b/crates/pixi_core/src/workspace/mod.rs index 10299e26f4..0aa009a2ca 100644 --- a/crates/pixi_core/src/workspace/mod.rs +++ b/crates/pixi_core/src/workspace/mod.rs @@ -206,11 +206,22 @@ impl Workspace { /// Constructs a new instance from an internal manifest representation pub(crate) fn from_manifests(manifest: Manifests) -> Self { let env_vars = Workspace::init_env_vars(&manifest.workspace.value.environments); - // Get the absolute path of the manifest, preserving symlinks by only - // canonicalizing the parent directory - let manifest_path = manifest.workspace.provenance.absolute_path(); - // Take the parent after canonicalizing to ensure this works even when the - // manifest + // Determine the manifest path based on whether symlinks should be resolved. + // When resolve_symlinks is None or true (default), fully canonicalize the path (resolving symlinks). + // When false, only canonicalize the parent directory to preserve symlinks. + let manifest_path = if manifest + .workspace + .value + .workspace + .resolve_symlinks + .unwrap_or(true) + { + dunce::canonicalize(&manifest.workspace.provenance.path) + .unwrap_or_else(|_| manifest.workspace.provenance.absolute_path()) + } else { + manifest.workspace.provenance.absolute_path() + }; + let root = manifest_path .parent() .expect("manifest path should always have a parent") @@ -1311,14 +1322,69 @@ mod tests { #[test] #[cfg(unix)] - fn test_workspace_root_preserves_symlink_location() { + fn test_workspace_root_resolves_symlinks_by_default() { + // This test reflects a package development workflow where a user symlinks + // a manifest from a parent directory to a package subdirectory, and wants + // path resolution to happen relative to the package (real file location). + // https://github.com/prefix-dev/pixi/issues/5148 + let temp_dir = tempfile::tempdir().unwrap(); + let parent_dir = temp_dir.path().join("parent"); + let pkg_dir = parent_dir.join("pkg"); + fs_err::create_dir_all(&parent_dir).unwrap(); + fs_err::create_dir_all(&pkg_dir).unwrap(); + + // Real manifest lives inside the package directory + let real_manifest = pkg_dir.join("pixi.toml"); + fs_err::write( + &real_manifest, + r#" + [workspace] + name = "test" + channels = [] + platforms = [] + "#, + ) + .unwrap(); + + // Parent directory contains a symlink that points at the package manifest + let symlink_manifest = parent_dir.join("pixi.toml"); + std::os::unix::fs::symlink(&real_manifest, &symlink_manifest).unwrap(); + + // Load workspace from the symlinked manifest path + let workspace = Workspace::from_path(&symlink_manifest).unwrap(); + + // By default (resolve-symlinks = true), the workspace root should be the + // pkg_dir (where the real file lives), NOT parent_dir (where the symlink lives) + let canonical_pkg = dunce::canonicalize(&pkg_dir).unwrap(); + assert_eq!( + workspace.root(), + canonical_pkg, + "workspace root should be the real file location by default" + ); + + // The .pixi directory should be created in the package directory + let expected_pixi_dir = canonical_pkg.join(consts::PIXI_DIR); + assert_eq!( + workspace.pixi_dir(), + expected_pixi_dir, + ".pixi directory should be in the real file's parent directory" + ); + } + + #[test] + #[cfg(unix)] + fn test_workspace_root_preserves_symlinks_when_disabled() { + // This test reflects a dotfiles workflow where the real manifest lives in + // a dotfiles repo but is symlinked to the home directory, and the user wants + // .pixi/ and path resolution to happen at the home directory (symlink location). + // https://github.com/prefix-dev/pixi/issues/4907 let temp_dir = tempfile::tempdir().unwrap(); let dotfiles_dir = temp_dir.path().join("dotfiles"); let home_dir = temp_dir.path().join("home"); fs_err::create_dir_all(&dotfiles_dir).unwrap(); fs_err::create_dir_all(&home_dir).unwrap(); - // Real manifest lives inside the dotfiles directory + // Real manifest lives inside the dotfiles directory with resolve-symlinks = false let real_manifest = dotfiles_dir.join("pixi.toml"); fs_err::write( &real_manifest, @@ -1327,24 +1393,25 @@ mod tests { name = "test" channels = [] platforms = [] + resolve-symlinks = false "#, ) .unwrap(); - // Home directory contains a symlink that points at the real manifest + // Home directory contains a symlink that points at the dotfiles manifest let symlink_manifest = home_dir.join("pixi.toml"); std::os::unix::fs::symlink(&real_manifest, &symlink_manifest).unwrap(); // Load workspace from the symlinked manifest path let workspace = Workspace::from_path(&symlink_manifest).unwrap(); - // The workspace root should be the home_dir (where the symlink lives), - // NOT the dotfiles_dir (where the real file lives) + // With resolve-symlinks = false, the workspace root should be the home_dir + // (where the symlink lives), NOT dotfiles_dir (where the real file lives) let canonical_home = dunce::canonicalize(&home_dir).unwrap(); assert_eq!( workspace.root(), canonical_home, - "workspace root should be relative to symlink location, not the real file location" + "workspace root should be the symlink location when resolve-symlinks = false" ); // The .pixi directory should be created in the home directory diff --git a/crates/pixi_manifest/src/toml/workspace.rs b/crates/pixi_manifest/src/toml/workspace.rs index 556e6ade98..42f57ee578 100644 --- a/crates/pixi_manifest/src/toml/workspace.rs +++ b/crates/pixi_manifest/src/toml/workspace.rs @@ -54,6 +54,7 @@ pub struct TomlWorkspace { pub build_variant_files: Option>>>, pub requires_pixi: Option, pub exclude_newer: Option, + pub resolve_symlinks: Option, pub span: Span, } @@ -147,6 +148,7 @@ impl TomlWorkspace { ), requires_pixi: self.requires_pixi, exclude_newer: self.exclude_newer, + resolve_symlinks: self.resolve_symlinks, }) .with_warnings(warnings)) } @@ -246,6 +248,7 @@ impl<'de> toml_span::Deserialize<'de> for TomlWorkspace { let exclude_newer = th .optional::>>("exclude-newer") .map(TomlWith::into_inner); + let resolve_symlinks = th.optional("resolve-symlinks"); th.finalize(None)?; @@ -273,6 +276,7 @@ impl<'de> toml_span::Deserialize<'de> for TomlWorkspace { build_variant_files, requires_pixi, exclude_newer, + resolve_symlinks, span: value.span, }) } diff --git a/crates/pixi_manifest/src/workspace.rs b/crates/pixi_manifest/src/workspace.rs index 7e1234c898..e04057eede 100644 --- a/crates/pixi_manifest/src/workspace.rs +++ b/crates/pixi_manifest/src/workspace.rs @@ -89,6 +89,9 @@ pub struct Workspace { /// Exclude package candidates that are newer than this date. pub exclude_newer: Option, + + /// Whether to resolve symlinked manifest paths to their real file location. + pub resolve_symlinks: Option, } /// A source that contributes additional build variant definitions. diff --git a/docs/reference/pixi_manifest.md b/docs/reference/pixi_manifest.md index 94bdcb9e26..86a840ef68 100644 --- a/docs/reference/pixi_manifest.md +++ b/docs/reference/pixi_manifest.md @@ -304,6 +304,15 @@ Both PyPi and conda packages are considered. !! note Note that for Pypi package indexes the package index must support the `upload-time` field as specified in [`PEP 700`](https://peps.python.org/pep-0700/). If the field is not present for a given distribution, the distribution will be treated as unavailable. PyPI provides `upload-time` for all packages. +### `resolve-symlinks` (optional) + +Controls how pixi handles symlinked manifest files. When `true` (the default), pixi resolves symlinks to determine the workspace root from the real file's location. When `false`, pixi uses the symlink's location as the workspace root. + +```toml +[workspace] +resolve-symlinks = false +``` + ### `build-variants` (optional) !!! warning "Preview Feature" diff --git a/schema/model.py b/schema/model.py index eed0f80c83..681881e0dc 100644 --- a/schema/model.py +++ b/schema/model.py @@ -203,6 +203,10 @@ class Workspace(StrictBaseModel): description="The required version spec for pixi itself to resolve and build the project.", examples=[">=0.40"], ) + resolve_symlinks: bool | None = Field( + None, + description="Whether to resolve symlinked manifest paths to their real file location. When true (default), the workspace root becomes the real file's directory. When false, the workspace root is where the symlink lives.", + ) target: dict[TargetName, WorkspaceTarget] | None = Field( None, description="The workspace targets" ) diff --git a/schema/schema.json b/schema/schema.json index 38b9a1c7de..23a7553bd0 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -2509,6 +2509,11 @@ ">=0.40" ] }, + "resolve-symlinks": { + "title": "Resolve-Symlinks", + "description": "Whether to resolve symlinked manifest paths to their real file location. When true (default), the workspace root becomes the real file's directory. When false, the workspace root is where the symlink lives.", + "type": "boolean" + }, "s3-options": { "title": "S3-Options", "description": "Options related to S3 for this project",