Skip to content

Commit

Permalink
feat: pixi global remove (#2226)
Browse files Browse the repository at this point in the history
Co-authored-by: Hofer-Julian <[email protected]>
  • Loading branch information
ruben-arts and Hofer-Julian authored Oct 11, 2024
1 parent d38f723 commit 7acc946
Show file tree
Hide file tree
Showing 7 changed files with 393 additions and 172 deletions.
97 changes: 90 additions & 7 deletions src/cli/global/remove.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
use clap::Parser;
use clap_verbosity_flag::Verbosity;

use crate::cli::global::revert_environment_after_error;
use crate::cli::has_specs::HasSpecs;
use crate::global::{EnvironmentName, ExposedName, Project, StateChanges};
use clap::Parser;
use itertools::Itertools;
use miette::Context;
use pixi_config::{Config, ConfigCli};
use rattler_conda_types::MatchSpec;
use std::str::FromStr;

/// Removes a package previously installed into a globally accessible location via `pixi global install`.
#[derive(Parser, Debug)]
Expand All @@ -11,8 +16,12 @@ pub struct Args {
#[arg(num_args = 1..)]
packages: Vec<String>,

#[command(flatten)]
verbose: Verbosity,
/// Specifies the environment that the dependencies need to be removed from.
#[clap(short, long, required = true)]
environment: EnvironmentName,

#[clap(flatten)]
config: ConfigCli,
}

impl HasSpecs for Args {
Expand All @@ -21,6 +30,80 @@ impl HasSpecs for Args {
}
}

pub async fn execute(_args: Args) -> miette::Result<()> {
todo!()
pub async fn execute(args: Args) -> miette::Result<()> {
let config = Config::with_cli_config(&args.config);
let project_original = Project::discover_or_create()
.await?
.with_cli_config(config.clone());

if project_original.environment(&args.environment).is_none() {
miette::bail!("Environment {} doesn't exist. You can create a new environment with `pixi global install`.", &args.environment);
}

async fn apply_changes(
env_name: &EnvironmentName,
specs: &[MatchSpec],
project: &mut Project,
) -> miette::Result<StateChanges> {
// Remove specs from the manifest
for spec in specs {
project.manifest.remove_dependency(env_name, spec)?;
}

// Figure out which package the exposed binaries belong to
let prefix = project.environment_prefix(env_name).await?;

for spec in specs {
if let Some(name) = spec.clone().name {
// If the package is not existent, don't try to remove executables
if let Ok(record) = prefix.find_designated_package(&name).await {
prefix
.find_executables(&[record])
.into_iter()
.filter_map(|(name, _path)| ExposedName::from_str(name.as_str()).ok())
.for_each(|exposed_name| {
project
.manifest
.remove_exposed_name(env_name, &exposed_name)
.ok();
});
}
}
}

// Sync environment
let state_changes = project.sync_environment(env_name).await?;

project.manifest.save().await?;
Ok(state_changes)
}

let mut project = project_original.clone();
let specs = args
.specs()?
.into_iter()
.map(|(_, specs)| specs)
.collect_vec();

match apply_changes(&args.environment, specs.as_slice(), &mut project)
.await
.wrap_err(format!(
"Couldn't remove packages from {}",
&args.environment
)) {
Ok(state_changes) => {
state_changes.report();
}
Err(err) => {
revert_environment_after_error(&args.environment, &project_original)
.await
.wrap_err(format!(
"Could not remove {:?}. Reverting also failed.",
args.packages
))?;
return Err(err);
}
}

Ok(())
}
151 changes: 150 additions & 1 deletion src/global/common.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
use super::{EnvironmentName, ExposedName};
use super::{extract_executable_from_script, EnvironmentName, ExposedName, Mapping};
use fancy_display::FancyDisplay;
use fs_err as fs;
use fs_err::tokio as tokio_fs;
use indexmap::IndexSet;
use is_executable::IsExecutable;
use itertools::Itertools;
use miette::{Context, IntoDiagnostic};
use pixi_config::home_path;
use pixi_manifest::PrioritizedChannel;
use pixi_utils::executable_from_path;
use rattler_conda_types::{Channel, ChannelConfig, NamedChannelOrUrl, PackageRecord, PrefixRecord};
use std::collections::HashMap;
use std::ffi::OsStr;
Expand Down Expand Up @@ -406,6 +409,67 @@ pub(crate) fn channel_url_to_prioritized_channel(
.into())
}

/// Figures out what the status is of the exposed binaries of the environment.
///
/// Returns a tuple of the exposed binaries to remove and the exposed binaries to add.
pub(crate) async fn get_expose_scripts_sync_status(
bin_dir: &BinDir,
env_dir: &EnvDir,
exposed: &IndexSet<Mapping>,
) -> miette::Result<(IndexSet<PathBuf>, IndexSet<ExposedName>)> {
// Get all paths to the binaries from the scripts in the bin directory.
let locally_exposed = bin_dir.files().await?;
let executable_paths = futures::future::join_all(locally_exposed.iter().map(|path| {
let path = path.clone();
async move {
extract_executable_from_script(&path)
.await
.ok()
.map(|exec| (path, exec))
}
}))
.await
.into_iter()
.flatten()
.collect_vec();

// Filter out all binaries that are related to the environment
let related_exposed = executable_paths
.into_iter()
.filter(|(_, exec)| exec.starts_with(env_dir.path()))
.map(|(path, _)| path)
.collect_vec();

// Get all related expose scripts not required by the environment manifest
let to_remove = related_exposed
.iter()
.filter(|path| {
!exposed
.iter()
.any(|mapping| executable_from_path(path) == mapping.exposed_name().to_string())
})
.cloned()
.collect::<IndexSet<PathBuf>>();

// Get all required exposed binaries that are not yet exposed
let to_add = exposed
.iter()
.filter_map(|mapping| {
if related_exposed
.iter()
.map(|path| executable_from_path(path))
.any(|exec| exec == mapping.exposed_name().to_string())
{
None
} else {
Some(mapping.exposed_name().clone())
}
})
.collect::<IndexSet<ExposedName>>();

Ok((to_remove, to_add))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -507,4 +571,89 @@ mod tests {
assert_eq!(executable_script_path, path.join(exposed_name.to_string()));
}
}

#[tokio::test]
async fn test_get_expose_scripts_sync_status() {
let tmp_home_dir = tempfile::tempdir().unwrap();
let tmp_home_dir_path = tmp_home_dir.path().to_path_buf();
let env_root = EnvRoot::new(tmp_home_dir_path.clone()).unwrap();
let env_name = EnvironmentName::from_str("test").unwrap();
let env_dir = EnvDir::from_env_root(env_root, &env_name).await.unwrap();
let bin_dir = BinDir::new(tmp_home_dir_path.clone()).unwrap();

// Test empty
let exposed = IndexSet::new();
let (to_remove, to_add) = get_expose_scripts_sync_status(&bin_dir, &env_dir, &exposed)
.await
.unwrap();
assert!(to_remove.is_empty());
assert!(to_add.is_empty());

// Test with exposed
let mut exposed = IndexSet::new();
exposed.insert(Mapping::new(
ExposedName::from_str("test").unwrap(),
"test".to_string(),
));
let (to_remove, to_add) = get_expose_scripts_sync_status(&bin_dir, &env_dir, &exposed)
.await
.unwrap();
assert!(to_remove.is_empty());
assert_eq!(to_add.len(), 1);

// Add a script to the bin directory
let script_path = if cfg!(windows) {
bin_dir.path().join("test.bat")
} else {
bin_dir.path().join("test")
};

#[cfg(windows)]
{
let script = format!(
r#"
@"{}" %*
"#,
env_dir
.path()
.join("bin")
.join("test.exe")
.to_string_lossy()
);
tokio_fs::write(&script_path, script).await.unwrap();
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;

let script = format!(
r#"#!/bin/sh
"{}" "$@"
"#,
env_dir.path().join("bin").join("test").to_string_lossy()
);
tokio_fs::write(&script_path, script).await.unwrap();
// Set the file permissions to make it executable
let metadata = tokio_fs::metadata(&script_path).await.unwrap();
let mut permissions = metadata.permissions();
permissions.set_mode(0o755); // rwxr-xr-x
tokio_fs::set_permissions(&script_path, permissions)
.await
.unwrap();
};

let (to_remove, to_add) = get_expose_scripts_sync_status(&bin_dir, &env_dir, &exposed)
.await
.unwrap();
assert!(to_remove.is_empty());
assert!(to_add.is_empty());

// Test to_remove
let (to_remove, to_add) =
get_expose_scripts_sync_status(&bin_dir, &env_dir, &IndexSet::new())
.await
.unwrap();
assert_eq!(to_remove.len(), 1);
assert!(to_add.is_empty());
}
}
Loading

0 comments on commit 7acc946

Please sign in to comment.