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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions crates/pixi_cli/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,5 +250,38 @@ pub async fn execute(args: Args) -> miette::Result<()> {

eprintln!("{message}.");

// Under the `hierarchical-tasks` preview feature, the workspace owns a
// tree of member workspaces. `pixi install` at the root is eager: we
// walk every reachable member and install its default environment,
// updating each member's own lockfile in the process. This matches
// the user-approved behaviour of "lazy install for tasks, eager
// install for `pixi install`".
//
// Errors from any member abort the command with the path prefix of
// the failing member so users can see exactly which project broke.
for (path, member_ws) in workspace.walk_members() {
let member_envs = vec![member_ws.default_environment()];
let prefix = path.join("::");
get_update_lock_file_and_prefixes(
&member_envs,
UpdateMode::Revalidate,
UpdateLockFileOptions {
lock_file_usage: args.lock_file_usage.to_usage(),
no_install: false,
max_concurrent_solves: member_ws.config().max_concurrent_solves(),
},
ReinstallPackages::default(),
&filter,
)
.await
.map_err(|e| e.wrap_err(format!("failed to install member workspace `{prefix}`")))?;

eprintln!(
"{} The {} member's default environment has been installed",
console::style(console::Emoji("✔ ", "")).green(),
console::style(&prefix).bold(),
);
}

Ok(())
}
76 changes: 67 additions & 9 deletions crates/pixi_cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::{
collections::{HashMap, HashSet, hash_map::Entry},
convert::identity,
ffi::OsString,
path::PathBuf,
string::String,
};

Expand All @@ -19,8 +20,8 @@ use pixi_config::{ConfigCli, ConfigCliActivation};
use pixi_core::{
Workspace, WorkspaceLocator,
environment::sanity_check_workspace,
lock_file::{ReinstallPackages, UpdateLockFileOptions, UpdateMode},
workspace::{Environment, errors::UnsupportedPlatformError},
lock_file::{LockFileDerivedData, ReinstallPackages, UpdateLockFileOptions, UpdateMode},
workspace::{Environment, HasWorkspaceRef, errors::UnsupportedPlatformError},
};
use pixi_manifest::{FeaturesExt, TaskName};
use pixi_progress::global_multi_progress;
Expand Down Expand Up @@ -217,6 +218,39 @@ pub async fn execute(args: Args) -> miette::Result<()> {
)?;
tracing::debug!("Task graph: {}", task_graph);

// Under the `hierarchical-tasks` preview feature, tasks may run in
// member workspaces (e.g. `pixi run a::test`). Each member has its
// own lockfile and its own `.pixi/envs/` install dir — so we can't
// reuse the root's `lock_file` for a member task. Collect the
// distinct member workspaces referenced by the graph and lazily
// update each one's lockfile. We look these up by workspace root
// path when executing tasks. The root workspace always uses the
// `lock_file` already computed above.
//
// When no member is referenced by the graph (the common case), this
// map is empty and behaviour is identical to pre-preview pixi.
let mut member_lock_files: HashMap<PathBuf, LockFileDerivedData<'_>> = HashMap::new();
{
let root_path = workspace.root().to_path_buf();
let mut seen: HashSet<PathBuf> = HashSet::from([root_path.clone()]);
for task_id in task_graph.topological_order() {
let node_ws: &Workspace = task_graph[task_id].run_environment.workspace();
let node_root = node_ws.root().to_path_buf();
if !seen.insert(node_root.clone()) {
continue;
}
let member_lf = node_ws
.update_lock_file(UpdateLockFileOptions {
lock_file_usage: args.lock_and_install_config.lock_file_usage()?,
no_install: args.lock_and_install_config.no_install(),
max_concurrent_solves: node_ws.config().max_concurrent_solves(),
})
.await?
.0;
member_lock_files.insert(node_root, member_lf);
}
}

// Print dry-run message if dry-run mode is enabled
if args.dry_run {
pixi_progress::println!(
Expand Down Expand Up @@ -288,9 +322,22 @@ pub async fn execute(args: Args) -> miette::Result<()> {
continue;
}

// Under `hierarchical-tasks`, the task may run in a member
// workspace; pick that workspace's lockfile instead of the
// root's. For the common case (task belongs to the root or the
// preview is off) this resolves to the root's `lock_file`.
let task_lock_file: &LockFileDerivedData<'_> =
if executable_task.workspace.root() == workspace.root() {
&lock_file
} else {
member_lock_files
.get(executable_task.workspace.root())
.expect("member lockfile must have been populated before the task loop runs")
};

// check task cache
let task_cache = match executable_task
.can_skip(lock_file.as_lock_file())
.can_skip(task_lock_file.as_lock_file())
.await
.into_diagnostic()?
{
Expand Down Expand Up @@ -322,8 +369,11 @@ pub async fn execute(args: Args) -> miette::Result<()> {
Entry::Vacant(entry) => {
// Check if we allow installs
if args.lock_and_install_config.allow_installs() {
// Ensure there is a valid prefix
lock_file
// Ensure there is a valid prefix. `task_lock_file`
// already matches `executable_task`'s workspace, so
// the install dir, lockfile, and command dispatcher
// all target the right workspace (root or member).
task_lock_file
.prefix(
&executable_task.run_environment,
UpdateMode::QuickValidate,
Expand All @@ -334,17 +384,25 @@ pub async fn execute(args: Args) -> miette::Result<()> {
}

// Clear the current progress reports.
task_lock_file.command_dispatcher.clear_reporter().await;
progress.on_clear();


// Clear caches based on the filesystem. The tasks might change files on disk.
lock_file.command_dispatcher.clear_filesystem_caches().await;
task_lock_file
.command_dispatcher
.clear_filesystem_caches()
.await;

let task_workspace = executable_task.workspace;
let command_env = get_task_env(
&executable_task.run_environment,
args.clean_env || executable_task.task().clean_env(),
Some(lock_file.as_lock_file()),
workspace.config().force_activate(),
workspace.config().experimental_activation_cache_usage(),
Some(task_lock_file.as_lock_file()),
task_workspace.config().force_activate(),
task_workspace
.config()
.experimental_activation_cache_usage(),
)
.await?;
entry.insert(command_env)
Expand Down
39 changes: 38 additions & 1 deletion crates/pixi_cli/src/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,13 +337,19 @@ async fn list_tasks(
return print_tasks_json(workspace_ctx.workspace());
}

let tasks_per_env = workspace_ctx
let mut tasks_per_env = workspace_ctx
.list_tasks(
args.environment
.and_then(|e| EnvironmentName::from_str(&e.to_string()).ok()),
)
.await?;

// Hierarchical-tasks: inject member tasks into the default environment
// listing with their fully-qualified `a::b::task` names. These tasks do
// not belong to any single environment — they live on member nodes — so
// we surface them under the default environment for display purposes.
inject_member_tasks_for_display(&mut tasks_per_env, workspace_ctx.workspace());

if tasks_per_env.is_empty() {
eprintln!("No tasks found",);
return Ok(());
Expand Down Expand Up @@ -536,3 +542,34 @@ impl From<&Task> for TaskInfo {
}
}
}

/// Walks the workspace's member tree and appends every reachable member task
/// to `tasks_per_env` under the default environment, using the fully
/// qualified `a::b::task` name. Does nothing when the tree is empty (the
/// usual case when `hierarchical-tasks` is disabled).
fn inject_member_tasks_for_display(
tasks_per_env: &mut HashMap<EnvironmentName, HashMap<TaskName, Task>>,
workspace: &Workspace,
) {
if workspace.members().is_empty() {
return;
}

let default_env_name = workspace.default_environment().name().clone();
let bucket = tasks_per_env.entry(default_env_name).or_default();

// Each member is its own standalone Workspace (Model 2). Its tasks
// live on the member's default environment — we surface them here
// under the root's default-env listing with fully-qualified names so
// `pixi task list` shows the entire reachable set without a caller
// needing to enter each member.
for (path, member_ws) in workspace.walk_members() {
let prefix = path.join("::");
if let Ok(member_tasks) = member_ws.default_environment().tasks(None) {
for (task_name, task) in member_tasks {
let qualified = format!("{prefix}::{}", task_name.as_str());
bucket.insert(TaskName::from(qualified), task.clone());
}
}
}
}
90 changes: 87 additions & 3 deletions crates/pixi_core/src/workspace/discovery.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use std::{path::PathBuf, sync::Arc};
use std::{
path::{Path, PathBuf},
sync::Arc,
};

use itertools::Itertools;
use miette::{Diagnostic, NamedSource, Report};
Expand All @@ -10,7 +13,7 @@ use pixi_manifest::{
use thiserror::Error;

use crate::workspace::WorkspaceRegistry;
use crate::workspace::{Workspace, WorkspaceRegistryError};
use crate::workspace::{MemberWorkspace, Workspace, WorkspaceRegistryError};

/// Defines where the search for the workspace should start.
#[derive(Debug, Clone, Default)]
Expand Down Expand Up @@ -136,6 +139,10 @@ pub enum WorkspaceLocatorError {
#[error(transparent)]
#[diagnostic(transparent)]
InvalidRequiresPixi(#[from] Box<pixi_manifest::InvalidRequiresPixiError>),

#[error(transparent)]
#[diagnostic(transparent)]
MemberDiscovery(#[from] Box<pixi_manifest::MemberDiscoveryError>),
}

impl WorkspaceLocator {
Expand Down Expand Up @@ -293,7 +300,30 @@ impl WorkspaceLocator {
);
}

let workspace = Workspace::from_manifests(discovered_manifests);
let mut workspace = Workspace::from_manifests(discovered_manifests);

// If the `hierarchical-tasks` preview feature is enabled on the
// discovered root workspace, recursively load each discovered
// member's own standalone Workspace and attach it to the root's
// member tree. Under Model 2 every member has its own [workspace]
// and is fully self-contained — we just load it via the same
// locator machinery, then key the results by member name.
if workspace
.workspace
.value
.workspace
.preview
.is_enabled(pixi_manifest::KnownPreviewFeature::HierarchicalTasks)
{
// Find the MemberTree that was populated during manifest
// discovery and load each member. This runs the full
// `WorkspaceLocator` for each member directory, which means
// each member gets the same discovery/canonicalisation/
// requires-pixi treatment as any standalone workspace.
let workspace_dir = workspace.root().to_path_buf();
let members = load_members_recursively(&workspace_dir)?;
workspace.set_members(members);
}

Ok(workspace)
}
Expand Down Expand Up @@ -341,6 +371,60 @@ impl WorkspaceLocator {
}
}

/// Recursively load each discovered member as its own standalone
/// [`Workspace`], keyed by the member's `[workspace].name`.
///
/// This is called by [`WorkspaceLocator::locate`] once the root workspace
/// has been constructed and the `hierarchical-tasks` preview feature is
/// enabled. Each member goes through the same [`WorkspaceLocator`] path as
/// a standalone workspace — so the member's own manifest discovery,
/// canonicalisation, and `requires-pixi` check all run unchanged.
///
/// The `workspace_dir` argument is the already-canonicalised root of the
/// outer workspace. Member discovery only descends from here; it never
/// climbs back upward, so a member's own upward walk reliably stops at
/// the member's `[workspace]` (and not the outer root).
fn load_members_recursively(
workspace_dir: &Path,
) -> Result<indexmap::IndexMap<String, MemberWorkspace>, WorkspaceLocatorError> {
// Structural discovery: names + directories only, no Workspace load yet.
let tree = pixi_manifest::discover_members(workspace_dir)
.map_err(Box::new)
.map_err(WorkspaceLocatorError::MemberDiscovery)?;

fn build(
nodes: &indexmap::IndexMap<String, pixi_manifest::MemberNode>,
) -> Result<indexmap::IndexMap<String, MemberWorkspace>, WorkspaceLocatorError> {
let mut out = indexmap::IndexMap::with_capacity(nodes.len());
for (name, node) in nodes {
// Load this member as its own standalone Workspace. We
// deliberately avoid `with_consider_environment` / warning
// emission so member loads don't pollute the root's output.
let member_ws = WorkspaceLocator::for_cli()
.with_consider_environment(false)
.with_emit_warnings(false)
.with_search_start(DiscoveryStart::SearchRoot(node.dir.clone()))
.locate()?;

// Recurse into this member's own nested members. Each level
// owns its own loaded Workspace; the tree mirrors the
// structure returned by `discover_members`.
let children = build(&node.children)?;

out.insert(
name.clone(),
MemberWorkspace {
workspace: member_ws,
children,
},
);
}
Ok(out)
}

build(tree.members())
}

#[cfg(test)]
mod test {
use std::path::Path;
Expand Down
Loading
Loading