Skip to content

Commit b6a580e

Browse files
committed
feat: add resolve-symlinks option to control symlink behavior in manifest paths
1 parent 13f8eca commit b6a580e

File tree

6 files changed

+103
-11
lines changed

6 files changed

+103
-11
lines changed

crates/pixi_core/src/workspace/mod.rs

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -206,11 +206,22 @@ impl Workspace {
206206
/// Constructs a new instance from an internal manifest representation
207207
pub(crate) fn from_manifests(manifest: Manifests) -> Self {
208208
let env_vars = Workspace::init_env_vars(&manifest.workspace.value.environments);
209-
// Get the absolute path of the manifest, preserving symlinks by only
210-
// canonicalizing the parent directory
211-
let manifest_path = manifest.workspace.provenance.absolute_path();
212-
// Take the parent after canonicalizing to ensure this works even when the
213-
// manifest
209+
// Determine the manifest path based on whether symlinks should be resolved.
210+
// When resolve_symlinks is None or true (default), fully canonicalize the path (resolving symlinks).
211+
// When false, only canonicalize the parent directory to preserve symlinks.
212+
let manifest_path = if manifest
213+
.workspace
214+
.value
215+
.workspace
216+
.resolve_symlinks
217+
.unwrap_or(true)
218+
{
219+
dunce::canonicalize(&manifest.workspace.provenance.path)
220+
.unwrap_or_else(|_| manifest.workspace.provenance.absolute_path())
221+
} else {
222+
manifest.workspace.provenance.absolute_path()
223+
};
224+
214225
let root = manifest_path
215226
.parent()
216227
.expect("manifest path should always have a parent")
@@ -1311,14 +1322,69 @@ mod tests {
13111322

13121323
#[test]
13131324
#[cfg(unix)]
1314-
fn test_workspace_root_preserves_symlink_location() {
1325+
fn test_workspace_root_resolves_symlinks_by_default() {
1326+
// This test reflects a package development workflow where a user symlinks
1327+
// a manifest from a parent directory to a package subdirectory, and wants
1328+
// path resolution to happen relative to the package (real file location).
1329+
// https://github.com/prefix-dev/pixi/issues/5148
1330+
let temp_dir = tempfile::tempdir().unwrap();
1331+
let parent_dir = temp_dir.path().join("parent");
1332+
let pkg_dir = parent_dir.join("pkg");
1333+
fs_err::create_dir_all(&parent_dir).unwrap();
1334+
fs_err::create_dir_all(&pkg_dir).unwrap();
1335+
1336+
// Real manifest lives inside the package directory
1337+
let real_manifest = pkg_dir.join("pixi.toml");
1338+
fs_err::write(
1339+
&real_manifest,
1340+
r#"
1341+
[workspace]
1342+
name = "test"
1343+
channels = []
1344+
platforms = []
1345+
"#,
1346+
)
1347+
.unwrap();
1348+
1349+
// Parent directory contains a symlink that points at the package manifest
1350+
let symlink_manifest = parent_dir.join("pixi.toml");
1351+
std::os::unix::fs::symlink(&real_manifest, &symlink_manifest).unwrap();
1352+
1353+
// Load workspace from the symlinked manifest path
1354+
let workspace = Workspace::from_path(&symlink_manifest).unwrap();
1355+
1356+
// By default (resolve-symlinks = true), the workspace root should be the
1357+
// pkg_dir (where the real file lives), NOT parent_dir (where the symlink lives)
1358+
let canonical_pkg = dunce::canonicalize(&pkg_dir).unwrap();
1359+
assert_eq!(
1360+
workspace.root(),
1361+
canonical_pkg,
1362+
"workspace root should be the real file location by default"
1363+
);
1364+
1365+
// The .pixi directory should be created in the package directory
1366+
let expected_pixi_dir = canonical_pkg.join(consts::PIXI_DIR);
1367+
assert_eq!(
1368+
workspace.pixi_dir(),
1369+
expected_pixi_dir,
1370+
".pixi directory should be in the real file's parent directory"
1371+
);
1372+
}
1373+
1374+
#[test]
1375+
#[cfg(unix)]
1376+
fn test_workspace_root_preserves_symlinks_when_disabled() {
1377+
// This test reflects a dotfiles workflow where the real manifest lives in
1378+
// a dotfiles repo but is symlinked to the home directory, and the user wants
1379+
// .pixi/ and path resolution to happen at the home directory (symlink location).
1380+
// https://github.com/prefix-dev/pixi/issues/4907
13151381
let temp_dir = tempfile::tempdir().unwrap();
13161382
let dotfiles_dir = temp_dir.path().join("dotfiles");
13171383
let home_dir = temp_dir.path().join("home");
13181384
fs_err::create_dir_all(&dotfiles_dir).unwrap();
13191385
fs_err::create_dir_all(&home_dir).unwrap();
13201386

1321-
// Real manifest lives inside the dotfiles directory
1387+
// Real manifest lives inside the dotfiles directory with resolve-symlinks = false
13221388
let real_manifest = dotfiles_dir.join("pixi.toml");
13231389
fs_err::write(
13241390
&real_manifest,
@@ -1327,24 +1393,25 @@ mod tests {
13271393
name = "test"
13281394
channels = []
13291395
platforms = []
1396+
resolve-symlinks = false
13301397
"#,
13311398
)
13321399
.unwrap();
13331400

1334-
// Home directory contains a symlink that points at the real manifest
1401+
// Home directory contains a symlink that points at the dotfiles manifest
13351402
let symlink_manifest = home_dir.join("pixi.toml");
13361403
std::os::unix::fs::symlink(&real_manifest, &symlink_manifest).unwrap();
13371404

13381405
// Load workspace from the symlinked manifest path
13391406
let workspace = Workspace::from_path(&symlink_manifest).unwrap();
13401407

1341-
// The workspace root should be the home_dir (where the symlink lives),
1342-
// NOT the dotfiles_dir (where the real file lives)
1408+
// With resolve-symlinks = false, the workspace root should be the home_dir
1409+
// (where the symlink lives), NOT dotfiles_dir (where the real file lives)
13431410
let canonical_home = dunce::canonicalize(&home_dir).unwrap();
13441411
assert_eq!(
13451412
workspace.root(),
13461413
canonical_home,
1347-
"workspace root should be relative to symlink location, not the real file location"
1414+
"workspace root should be the symlink location when resolve-symlinks = false"
13481415
);
13491416

13501417
// The .pixi directory should be created in the home directory

crates/pixi_manifest/src/toml/workspace.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ pub struct TomlWorkspace {
5454
pub build_variant_files: Option<Vec<Spanned<TomlFromStr<PathBuf>>>>,
5555
pub requires_pixi: Option<VersionSpec>,
5656
pub exclude_newer: Option<ExcludeNewer>,
57+
pub resolve_symlinks: Option<bool>,
5758

5859
pub span: Span,
5960
}
@@ -147,6 +148,7 @@ impl TomlWorkspace {
147148
),
148149
requires_pixi: self.requires_pixi,
149150
exclude_newer: self.exclude_newer,
151+
resolve_symlinks: self.resolve_symlinks,
150152
})
151153
.with_warnings(warnings))
152154
}
@@ -246,6 +248,7 @@ impl<'de> toml_span::Deserialize<'de> for TomlWorkspace {
246248
let exclude_newer = th
247249
.optional::<TomlWith<_, TomlFromStr<_>>>("exclude-newer")
248250
.map(TomlWith::into_inner);
251+
let resolve_symlinks = th.optional("resolve-symlinks");
249252

250253
th.finalize(None)?;
251254

@@ -273,6 +276,7 @@ impl<'de> toml_span::Deserialize<'de> for TomlWorkspace {
273276
build_variant_files,
274277
requires_pixi,
275278
exclude_newer,
279+
resolve_symlinks,
276280
span: value.span,
277281
})
278282
}

crates/pixi_manifest/src/workspace.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ pub struct Workspace {
8989

9090
/// Exclude package candidates that are newer than this date.
9191
pub exclude_newer: Option<ExcludeNewer>,
92+
93+
/// Whether to resolve symlinked manifest paths to their real file location.
94+
pub resolve_symlinks: Option<bool>,
9295
}
9396

9497
/// A source that contributes additional build variant definitions.

docs/reference/pixi_manifest.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,15 @@ Both PyPi and conda packages are considered.
304304
!! 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/).
305305
If the field is not present for a given distribution, the distribution will be treated as unavailable. PyPI provides `upload-time` for all packages.
306306

307+
### `resolve-symlinks` (optional)
308+
309+
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.
310+
311+
```toml
312+
[workspace]
313+
resolve-symlinks = false
314+
```
315+
307316
### `build-variants` (optional)
308317

309318
!!! warning "Preview Feature"

schema/model.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,10 @@ class Workspace(StrictBaseModel):
203203
description="The required version spec for pixi itself to resolve and build the project.",
204204
examples=[">=0.40"],
205205
)
206+
resolve_symlinks: bool | None = Field(
207+
None,
208+
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.",
209+
)
206210
target: dict[TargetName, WorkspaceTarget] | None = Field(
207211
None, description="The workspace targets"
208212
)

schema/schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2509,6 +2509,11 @@
25092509
">=0.40"
25102510
]
25112511
},
2512+
"resolve-symlinks": {
2513+
"title": "Resolve-Symlinks",
2514+
"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.",
2515+
"type": "boolean"
2516+
},
25122517
"s3-options": {
25132518
"title": "S3-Options",
25142519
"description": "Options related to S3 for this project",

0 commit comments

Comments
 (0)