Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ jobs:
target: x86_64-unknown-linux-gnu
artifact_name: pvm
asset_name: pvm-linux-x86_64
# install.sh and self-update both resolve this asset on ARM Linux
# (get_target_triple returns linux-aarch64); without it they 404.
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
artifact_name: pvm
asset_name: pvm-linux-aarch64
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: pvm
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ The hard part: a child process cannot mutate its parent shell's environment. PVM
3. The Rust binary writes `export PATH=...; export PVM_MULTISHELL_PATH=...` to that tmpfile (with an exclusive file lock — see `fs::write_env_file_locked`).
4. The wrapper `eval`s the tmpfile and removes it.

The `Shell` trait (`Bash`, `Zsh`, `Fish`) emits the syntax for each shell; `detect_shell()` reads `$SHELL`. Bash and Zsh share the `posix_*` free functions; only `use_on_cd` differs. The `cd` hook reads `.php-version` and calls `pvm use --silent` automatically. `deactivate()` (used by `pvm use system`) writes an env-file snippet that clears `PVM_MULTISHELL_PATH` and filters `$PVM_DIR/versions` entries out of PATH.
The `Shell` trait (`Bash`, `Zsh`, `Fish`) emits the syntax for each shell; `detect_shell()` reads `$SHELL`. Bash and Zsh share the `posix_*` free functions; only `use_on_cd` differs. The `cd` hook reads `.php-version` and calls `pvm use --silent` automatically. `deactivate()` (used by `pvm use system`) writes an env-file snippet that clears `PVM_MULTISHELL_PATH` and restores the filtered PATH.

PATH is never manipulated in shell code: `Shell::path` and `Shell::deactivate` take an already-filtered entry list from `fs::path_without_versions()` and bake the whole value in literally. Because the wrapper runs `pvm` as a direct child, the Rust process sees exactly the PATH its output will be eval'd into, so it can drop stale `$PVM_DIR/versions` entries itself. Emitting `PATH=<new>:$PATH` instead would stack a duplicate on every activation — and the bash `cd` hook runs from `PROMPT_COMMAND`, i.e. after *every* command, so that grows without bound (hence the `__pvm_last_pwd` guard in `Bash::use_on_cd` and the `case`/`contains` guard around the `$PVM_DIR/bin` prepend in `wrapper_fn`).

The activation tail (patch-update offer, `.php-version` prompts, env-file write) lives in `use_cmd::activate(version, ActivateOpts)`; both `pvm use` and the interactive `pvm ls` picker call it. `ActivateOpts.offer_save` distinguishes picker flows (offer to write `.php-version`) from explicit-argument flows (offer to update an existing differing file).

Expand Down
10 changes: 6 additions & 4 deletions src/commands/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,18 @@ impl Env {
};

println!("{}", s.set_env_var(PVM_DIR_VAR, &pvm_dir.to_string_lossy()));
println!("{}", s.wrapper_fn());
println!("{}", s.use_on_cd());

// Activate the persisted default version ('pvm default') so every new
// shell starts on it instead of the system PHP.
// shell starts on it instead of the system PHP. Must precede wrapper_fn:
// its PATH is a snapshot taken before the wrapper adds $PVM_DIR/bin.
if let Some(version) = crate::fs::get_default_version()? {
if crate::fs::is_version_installed(&version)? {
let bin_dir = crate::fs::get_version_bin_dir(&version)?;
println!(
"{}",
s.set_env_var(MULTISHELL_PATH_VAR, &bin_dir.to_string_lossy())
);
println!("{}", s.path(&bin_dir));
println!("{}", s.path(&bin_dir, &crate::fs::path_without_versions()?));
} else {
// stderr so the eval'd stdout stays clean.
eprintln!(
Expand All @@ -50,6 +49,9 @@ impl Env {
}
}

println!("{}", s.wrapper_fn());
println!("{}", s.use_on_cd());

Ok(())
}
}
2 changes: 1 addition & 1 deletion src/commands/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ pub async fn execute_install_with(
let bin_dir = crate::fs::get_version_bin_dir(&v)?;
let s = crate::shell::detect_shell();
let export_str1 = s.set_env_var(MULTISHELL_PATH_VAR, &bin_dir.to_string_lossy());
let export_str2 = s.path(&bin_dir);
let export_str2 = s.path(&bin_dir, &crate::fs::path_without_versions()?);

let env_file = crate::fs::get_env_update_path()?;
crate::fs::write_env_file_locked(&env_file, &format!("{}\n{}", export_str1, export_str2))?;
Expand Down
4 changes: 2 additions & 2 deletions src/commands/use_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ fn save_question(version: &str, file_version: &Option<String>, offer_save: bool)
fn switch_to_system(quiet: bool) -> Result<()> {
let s = shell::detect_shell();
let env_file = fs::get_env_update_path()?;
fs::write_env_file_locked(&env_file, &s.deactivate(&fs::get_versions_dir()?))?;
fs::write_env_file_locked(&env_file, &s.deactivate(&fs::path_without_versions()?))?;
if !quiet {
eprintln!("{} Switched to system PHP", "✓".green());
}
Expand Down Expand Up @@ -298,7 +298,7 @@ pub(crate) async fn activate(mut version: String, opts: ActivateOpts) -> Result<

// These evaluate in the user's shell hook via wrapper
let export_str1 = s.set_env_var(MULTISHELL_PATH_VAR, &bin_dir.to_string_lossy());
let export_str2 = s.path(&bin_dir);
let export_str2 = s.path(&bin_dir, &fs::path_without_versions()?);

let env_file = fs::get_env_update_path()?;
fs::write_env_file_locked(&env_file, &format!("{}\n{}", export_str1, export_str2))?;
Expand Down
11 changes: 11 additions & 0 deletions src/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ pub fn get_current_version() -> String {
"system".to_string()
}

/// The inherited PATH with every `$PVM_DIR/versions` entry dropped — the base
/// an activation prepends to, so repeated switches cannot stack duplicates.
pub fn path_without_versions() -> Result<Vec<String>> {
let versions_dir = get_versions_dir()?;
let path = std::env::var_os("PATH").unwrap_or_default();
Ok(std::env::split_paths(&path)
.filter(|p| !p.starts_with(&versions_dir))
.map(|p| p.to_string_lossy().into_owned())
.collect())
}

pub fn get_env_update_path() -> Result<PathBuf> {
if let Ok(env_path) = std::env::var("PVM_ENV_UPDATE_PATH") {
return Ok(PathBuf::from(env_path));
Expand Down
Loading