diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad1397c..e1280fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 53a75cd..c0425a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=:$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). diff --git a/src/commands/env.rs b/src/commands/env.rs index bad1104..2ab4fef 100644 --- a/src/commands/env.rs +++ b/src/commands/env.rs @@ -28,11 +28,10 @@ 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)?; @@ -40,7 +39,7 @@ impl Env { "{}", 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!( @@ -50,6 +49,9 @@ impl Env { } } + println!("{}", s.wrapper_fn()); + println!("{}", s.use_on_cd()); + Ok(()) } } diff --git a/src/commands/install.rs b/src/commands/install.rs index aef3c37..6912d08 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -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))?; diff --git a/src/commands/use_cmd.rs b/src/commands/use_cmd.rs index e132d0c..c22156a 100644 --- a/src/commands/use_cmd.rs +++ b/src/commands/use_cmd.rs @@ -204,7 +204,7 @@ fn save_question(version: &str, file_version: &Option, 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()); } @@ -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))?; diff --git a/src/fs.rs b/src/fs.rs index 7ca7408..a7dfc01 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -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> { + 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 { if let Ok(env_path) = std::env::var("PVM_ENV_UPDATE_PATH") { return Ok(PathBuf::from(env_path)); diff --git a/src/shell.rs b/src/shell.rs index 5a05eaa..3347a91 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -2,13 +2,15 @@ use crate::constants::{ENV_UPDATE_FILE, PVM_DIR_VAR}; use std::path::Path; pub trait Shell { - fn path(&self, path: &Path) -> String; + /// Emit a PATH assignment of `bin_dir` followed by `rest`, an already + /// pvm-free entry list from `fs::path_without_versions` (never `$PATH`). + fn path(&self, bin_dir: &Path, rest: &[String]) -> String; fn set_env_var(&self, name: &str, value: &str) -> String; fn use_on_cd(&self) -> String; fn wrapper_fn(&self) -> String; /// Emit commands that drop every pvm-managed entry from PATH and clear /// the multishell marker, returning the shell to the system PHP. - fn deactivate(&self, versions_dir: &Path) -> String; + fn deactivate(&self, rest: &[String]) -> String; } /// Quote a string for POSIX shells (bash/zsh) by wrapping it in single quotes @@ -42,11 +44,28 @@ fn fish_single_quote(value: &str) -> String { out } -fn posix_path(path: &Path) -> String { - format!( - "export PATH={}:\"$PATH\"", - posix_single_quote(&path.display().to_string()) - ) +/// `head` (if any) followed by `rest`, empty entries dropped: a stray `::` or a +/// trailing `:` in the inherited PATH means "current directory" to the shell. +fn path_entries<'a>(head: Option<&'a str>, rest: &'a [String]) -> impl Iterator { + head.into_iter() + .chain(rest.iter().map(String::as_str)) + .filter(|p| !p.is_empty()) +} + +fn posix_path_assignment<'a>(entries: impl Iterator) -> String { + let joined = entries.collect::>().join(":"); + format!("export PATH={}", posix_single_quote(&joined)) +} + +/// Quote each entry separately: PATH is a list in fish, not a `:`-joined string. +fn fish_path_assignment<'a>(entries: impl Iterator) -> String { + let words: Vec = entries.map(fish_single_quote).collect(); + format!("set -gx PATH {}", words.join(" ")) +} + +fn posix_path(bin_dir: &Path, rest: &[String]) -> String { + let bin = bin_dir.to_string_lossy(); + posix_path_assignment(path_entries(Some(bin.as_ref()), rest)) } fn posix_set_env_var(name: &str, value: &str) -> String { @@ -56,15 +75,18 @@ fn posix_set_env_var(name: &str, value: &str) -> String { fn posix_wrapper_fn() -> String { format!( " -export PATH=\"${{{}}}/bin:$PATH\" +case \":$PATH:\" in + *\":${{{d}}}/bin:\"*) ;; + *) export PATH=\"${{{d}}}/bin:$PATH\" ;; +esac pvm() {{ local command=$1 if [[ \"$command\" == \"env\" ]]; then command pvm \"$@\" else - if [[ -n \"${{{}}}\" && -d \"${{{}}}\" ]]; then - local env_file=\"${{{}}}/{}_$$_${{RANDOM}}${{RANDOM}}_$(date +%s)\" + if [[ -n \"${{{d}}}\" && -d \"${{{d}}}\" ]]; then + local env_file=\"${{{d}}}/{f}_$$_${{RANDOM}}${{RANDOM}}_$(date +%s)\" [[ -f \"$env_file\" ]] && command rm -f \"$env_file\" 2>/dev/null PVM_ENV_UPDATE_PATH=\"$env_file\" command pvm \"$@\" local exit_code=$? @@ -79,22 +101,23 @@ pvm() {{ fi }} ", - PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, ENV_UPDATE_FILE + d = PVM_DIR_VAR, + f = ENV_UPDATE_FILE ) } -fn posix_deactivate(versions_dir: &Path) -> String { +fn posix_deactivate(rest: &[String]) -> String { format!( - "export PVM_MULTISHELL_PATH=''\nexport PATH=\"$(printf '%s' \"$PATH\" | tr ':' '\\n' | grep -vF {} | paste -sd : -)\"", - posix_single_quote(&versions_dir.display().to_string()) + "export PVM_MULTISHELL_PATH=''\n{}", + posix_path_assignment(path_entries(None, rest)) ) } pub struct Bash; impl Shell for Bash { - fn path(&self, path: &Path) -> String { - posix_path(path) + fn path(&self, bin_dir: &Path, rest: &[String]) -> String { + posix_path(bin_dir, rest) } fn set_env_var(&self, name: &str, value: &str) -> String { @@ -102,8 +125,13 @@ impl Shell for Bash { } fn use_on_cd(&self) -> String { + // Bash has no chpwd hook, so this runs from PROMPT_COMMAND — i.e. after + // every command, not just after a cd. Bail out unless the directory + // actually changed, or every prompt would fork a pvm process. " _pvm_cd_hook() { + [[ \"$PWD\" == \"${__pvm_last_pwd-}\" ]] && return + __pvm_last_pwd=\"$PWD\" if [[ -f .php-version ]]; then pvm use --silent \"$(cat .php-version)\" || true fi @@ -121,16 +149,16 @@ fi posix_wrapper_fn() } - fn deactivate(&self, versions_dir: &Path) -> String { - posix_deactivate(versions_dir) + fn deactivate(&self, rest: &[String]) -> String { + posix_deactivate(rest) } } pub struct Zsh; impl Shell for Zsh { - fn path(&self, path: &Path) -> String { - posix_path(path) + fn path(&self, bin_dir: &Path, rest: &[String]) -> String { + posix_path(bin_dir, rest) } fn set_env_var(&self, name: &str, value: &str) -> String { @@ -154,19 +182,17 @@ add-zsh-hook chpwd _pvm_cd_hook posix_wrapper_fn() } - fn deactivate(&self, versions_dir: &Path) -> String { - posix_deactivate(versions_dir) + fn deactivate(&self, rest: &[String]) -> String { + posix_deactivate(rest) } } pub struct Fish; impl Shell for Fish { - fn path(&self, path: &Path) -> String { - format!( - "set -gx PATH {} $PATH", - fish_single_quote(&path.display().to_string()) - ) + fn path(&self, bin_dir: &Path, rest: &[String]) -> String { + let bin = bin_dir.to_string_lossy(); + fish_path_assignment(path_entries(Some(bin.as_ref()), rest)) } fn set_env_var(&self, name: &str, value: &str) -> String { @@ -184,25 +210,27 @@ end .to_string() } - fn deactivate(&self, versions_dir: &Path) -> String { + fn deactivate(&self, rest: &[String]) -> String { format!( - "set -gx PVM_MULTISHELL_PATH ''\nset -gx PATH (string match -v -- {} $PATH)", - fish_single_quote(&format!("{}*", versions_dir.display())) + "set -gx PVM_MULTISHELL_PATH ''\n{}", + fish_path_assignment(path_entries(None, rest)) ) } fn wrapper_fn(&self) -> String { format!( " -set -gx PATH \"${}/bin\" $PATH +if not contains \"${d}/bin\" $PATH + set -gx PATH \"${d}/bin\" $PATH +end function pvm set command $argv[1] if test \"$command\" = \"env\" command pvm $argv else - if test -n \"${}\"; and test -d \"${}\" - set env_file \"${}/{}_$fish_pid\"_(random)(random)_(date +%s) + if test -n \"${d}\"; and test -d \"${d}\" + set env_file \"${d}/{f}_$fish_pid\"_(random)(random)_(date +%s) if test -f \"$env_file\" command rm -f \"$env_file\" &>/dev/null end @@ -219,7 +247,8 @@ function pvm end end ", - PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, ENV_UPDATE_FILE + d = PVM_DIR_VAR, + f = ENV_UPDATE_FILE ) } } @@ -239,16 +268,58 @@ pub fn detect_shell() -> Box { mod tests { use super::*; + /// Stand-in for the non-pvm part of PATH. Deliberately synthetic: nothing + /// here may depend on how the host or CI runner lays out its directories. + fn rest() -> Vec { + vec!["/opt/tool-a/bin".to_string(), "/opt/tool-b/bin".to_string()] + } + #[test] fn test_bash_path_generation() { let bash = Bash; let path = std::path::Path::new("/home/user/.local/share/pvm/versions/8.3.1/bin"); assert_eq!( - bash.path(path), - "export PATH='/home/user/.local/share/pvm/versions/8.3.1/bin':\"$PATH\"" + bash.path(path, &rest()), + "export PATH='/home/user/.local/share/pvm/versions/8.3.1/bin:/opt/tool-a/bin:/opt/tool-b/bin'" + ); + } + + #[test] + fn test_posix_path_drops_empty_entries() { + // A trailing/duplicated ':' in PATH means "current directory" — never + // let one survive into the emitted assignment. + let bash = Bash; + let out = bash.path( + std::path::Path::new("/pvm/versions/8.3.1/bin"), + &[ + "".to_string(), + "/opt/tool-a/bin".to_string(), + "".to_string(), + ], + ); + assert_eq!(out, "export PATH='/pvm/versions/8.3.1/bin:/opt/tool-a/bin'"); + } + + #[test] + fn test_deactivate_drops_empty_entries() { + // Same guarantee on the way out: 'pvm use system' must not hand back a + // PATH whose empty entry silently means the current directory. + let out = Bash.deactivate(&["/opt/tool-a/bin".to_string(), "".to_string()]); + assert_eq!( + out, + "export PVM_MULTISHELL_PATH=''\nexport PATH='/opt/tool-a/bin'" ); } + #[test] + fn test_path_does_not_reference_shell_path_var() { + // The whole point of baking the value in: re-activating must replace the + // previous pvm entry, not prepend on top of the live $PATH again. + let bash = Bash; + let out = bash.path(std::path::Path::new("/pvm/versions/8.3.1/bin"), &rest()); + assert!(!out.contains("$PATH"), "got: {}", out); + } + #[test] fn test_bash_set_env() { let bash = Bash; @@ -272,8 +343,8 @@ mod tests { let zsh = Zsh; let path = std::path::Path::new("/home/user/.local/share/pvm/versions/8.3.1/bin"); assert_eq!( - zsh.path(path), - "export PATH='/home/user/.local/share/pvm/versions/8.3.1/bin':\"$PATH\"" + zsh.path(path, &rest()), + "export PATH='/home/user/.local/share/pvm/versions/8.3.1/bin:/opt/tool-a/bin:/opt/tool-b/bin'" ); } @@ -282,8 +353,8 @@ mod tests { let fish = Fish; let path = std::path::Path::new("/home/user/.local/share/pvm/versions/8.3.1/bin"); assert_eq!( - fish.path(path), - "set -gx PATH '/home/user/.local/share/pvm/versions/8.3.1/bin' $PATH" + fish.path(path, &rest()), + "set -gx PATH '/home/user/.local/share/pvm/versions/8.3.1/bin' '/opt/tool-a/bin' '/opt/tool-b/bin'" ); } @@ -294,19 +365,61 @@ mod tests { } #[test] - fn test_bash_deactivate_filters_versions_dir() { + fn test_bash_deactivate_restores_filtered_path() { let bash = Bash; - let out = bash.deactivate(std::path::Path::new("/data/pvm/versions")); - assert!(out.contains("export PVM_MULTISHELL_PATH=''")); - assert!(out.contains("grep -vF '/data/pvm/versions'")); - assert!(out.contains("paste -sd : -")); + let out = bash.deactivate(&rest()); + assert_eq!( + out, + "export PVM_MULTISHELL_PATH=''\nexport PATH='/opt/tool-a/bin:/opt/tool-b/bin'" + ); } #[test] - fn test_fish_deactivate_filters_versions_dir() { + fn test_fish_deactivate_restores_filtered_path() { let fish = Fish; - let out = fish.deactivate(std::path::Path::new("/data/pvm/versions")); - assert!(out.contains("set -gx PVM_MULTISHELL_PATH ''")); - assert!(out.contains("string match -v -- '/data/pvm/versions*' $PATH")); + let out = fish.deactivate(&rest()); + assert_eq!( + out, + "set -gx PVM_MULTISHELL_PATH ''\nset -gx PATH '/opt/tool-a/bin' '/opt/tool-b/bin'" + ); + } + + /// Index of `needle`, or a failure naming the snippet that lacked it. + fn offset_of(haystack: &str, needle: &str) -> usize { + haystack + .find(needle) + .unwrap_or_else(|| panic!("{:?} missing from:\n{}", needle, haystack)) + } + + #[test] + fn test_bash_cd_hook_skips_unchanged_pwd() { + // PROMPT_COMMAND fires after every command, not just after a cd, so the + // hook has to compare $PWD and bail before it ever reads .php-version — + // otherwise every prompt forks a pvm process. + let hook = Bash.use_on_cd(); + assert!( + offset_of(&hook, "__pvm_last_pwd") < offset_of(&hook, ".php-version"), + "cd hook reads .php-version before the $PWD guard:\n{}", + hook + ); + } + + #[test] + fn test_wrapper_fn_guards_against_duplicate_bin_entry() { + // Re-sourcing the rc file (nested shells, `exec bash`) must not stack + // another $PVM_DIR/bin entry onto PATH, so the prepend has to sit behind + // a membership test rather than run unconditionally. + let bash = Bash.wrapper_fn(); + assert!( + offset_of(&bash, "case \":$PATH:\"") < offset_of(&bash, "/bin:$PATH"), + "bash prepends $PVM_DIR/bin unguarded:\n{}", + bash + ); + let fish = Fish.wrapper_fn(); + assert!( + offset_of(&fish, "if not contains") < offset_of(&fish, "set -gx PATH"), + "fish prepends $PVM_DIR/bin unguarded:\n{}", + fish + ); } } diff --git a/tests/cli.rs b/tests/cli.rs index 9705d32..3f7a9a9 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,5 +1,10 @@ use predicates::prelude::*; +/// The non-pvm part of a fabricated PATH. Deliberately synthetic so PATH +/// assertions never depend on how the host or CI runner is laid out; pvm itself +/// never resolves a binary through PATH, so these need not exist. +const OTHER_PATH: &str = "/opt/tool-a/bin:/opt/tool-b/bin"; + /// Seed the remote version cache so commands that hit the network resolve /// entirely offline. Mirrors the cache filename scheme in network.rs. fn seed_remote_cache(pvm_dir: &std::path::Path, versions: &[(&str, &[&str])]) { @@ -270,11 +275,13 @@ fn test_prune_non_tty_requires_yes() { fn test_use_system_writes_deactivation_env_file() { let temp_dir = tempfile::tempdir().unwrap(); let env_file = temp_dir.path().join("custom_env_update"); + let active_bin = temp_dir.path().join("versions/8.9.6/bin"); let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); cmd.env("PVM_DIR", temp_dir.path()); cmd.env("PVM_ENV_UPDATE_PATH", &env_file); cmd.env("SHELL", "/bin/bash"); + cmd.env("PATH", format!("{}:{}", active_bin.display(), OTHER_PATH)); cmd.arg("use").arg("system"); cmd.assert() .success() @@ -282,8 +289,62 @@ fn test_use_system_writes_deactivation_env_file() { let content = std::fs::read_to_string(env_file).unwrap(); assert!(content.contains("export PVM_MULTISHELL_PATH=''")); - assert!(content.contains("grep -vF")); - assert!(content.contains("versions")); + assert!( + content.contains(&format!("export PATH='{}'", OTHER_PATH)), + "{}", + content + ); + assert!(!content.contains("versions"), "{}", content); +} + +/// Regression: every activation used to prepend its bin dir to the live $PATH, +/// so the cd-hook stacked a fresh duplicate on every switch (in bash: on every +/// prompt) until PATH was hundreds of entries long. +#[test] +fn test_use_does_not_stack_duplicate_path_entries() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + seed_installed_version(temp_dir.path(), "8.8.1", &["cli"]); + let bin_896 = temp_dir.path().join("versions/8.9.6/bin"); + let bin_881 = temp_dir.path().join("versions/8.8.1/bin"); + let env_file = temp_dir.path().join("custom_env_update"); + + // Simulate a shell that already has 8.9.6 active twice over plus a stale + // 8.8.1 entry, i.e. the state the old code kept growing. + let dirty_path = format!( + "{}:{}:{}:{}", + bin_896.display(), + bin_896.display(), + bin_881.display(), + OTHER_PATH + ); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env("PVM_ENV_UPDATE_PATH", &env_file); + cmd.env("SHELL", "/bin/bash"); + cmd.env("PATH", &dirty_path); + cmd.env_remove("PVM_MULTISHELL_PATH"); + cmd.arg("use").arg("--silent").arg("8.9.6"); + cmd.assert().success(); + + let content = std::fs::read_to_string(&env_file).unwrap(); + let path_line = content + .lines() + .find(|l| l.starts_with("export PATH=")) + .expect("no PATH line"); + assert_eq!( + path_line.matches(&*bin_896.to_string_lossy()).count(), + 1, + "active version listed more than once: {}", + path_line + ); + assert!( + !path_line.contains(&*bin_881.to_string_lossy()), + "stale version survived: {}", + path_line + ); + assert!(path_line.contains(OTHER_PATH), "{}", path_line); } #[test]