Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 78 additions & 11 deletions crates/pixi_core/src/workspace/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions crates/pixi_manifest/src/toml/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ pub struct TomlWorkspace {
pub build_variant_files: Option<Vec<Spanned<TomlFromStr<PathBuf>>>>,
pub requires_pixi: Option<VersionSpec>,
pub exclude_newer: Option<ExcludeNewer>,
pub resolve_symlinks: Option<bool>,

pub span: Span,
}
Expand Down Expand Up @@ -147,6 +148,7 @@ impl TomlWorkspace {
),
requires_pixi: self.requires_pixi,
exclude_newer: self.exclude_newer,
resolve_symlinks: self.resolve_symlinks,
})
.with_warnings(warnings))
}
Expand Down Expand Up @@ -246,6 +248,7 @@ impl<'de> toml_span::Deserialize<'de> for TomlWorkspace {
let exclude_newer = th
.optional::<TomlWith<_, TomlFromStr<_>>>("exclude-newer")
.map(TomlWith::into_inner);
let resolve_symlinks = th.optional("resolve-symlinks");

th.finalize(None)?;

Expand Down Expand Up @@ -273,6 +276,7 @@ impl<'de> toml_span::Deserialize<'de> for TomlWorkspace {
build_variant_files,
requires_pixi,
exclude_newer,
resolve_symlinks,
span: value.span,
})
}
Expand Down
3 changes: 3 additions & 0 deletions crates/pixi_manifest/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ pub struct Workspace {

/// Exclude package candidates that are newer than this date.
pub exclude_newer: Option<ExcludeNewer>,

/// Whether to resolve symlinked manifest paths to their real file location.
pub resolve_symlinks: Option<bool>,
}

/// A source that contributes additional build variant definitions.
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/pixi_manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions schema/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
5 changes: 5 additions & 0 deletions schema/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading