From d42ccb22aa7a1005f78268da15b450761d8670f2 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Tue, 13 Jan 2026 10:06:18 +0100 Subject: [PATCH 1/5] fix rm -rf * failing on non-existent files --- src/shell/execute.rs | 4 +--- tests/integration_test.rs | 39 ++++++++++++++++++++++++++++++++++----- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/shell/execute.rs b/src/shell/execute.rs index 23be78a..c858fb5 100644 --- a/src/shell/execute.rs +++ b/src/shell/execute.rs @@ -748,8 +748,6 @@ pub enum EvaluateWordTextError { }, #[error("glob: no matches found '{}'. Pattern part was not valid utf-8", part.to_string_lossy())] NotUtf8Pattern { part: OsString }, - #[error("glob: no matches found '{}'", pattern)] - NoFilesMatched { pattern: String }, #[error("invalid utf-8: {}", err)] InvalidUtf8 { #[from] @@ -862,7 +860,7 @@ fn evaluate_word_parts( let paths = paths.into_iter().filter_map(|p| p.ok()).collect::>(); if paths.is_empty() { - Err(EvaluateWordTextError::NoFilesMatched { pattern }) + Ok(vec![]) } else { let paths = if is_absolute { paths diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 829e5d6..6aad61b 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1067,6 +1067,29 @@ async fn rm() { .assert_exit_code(1) .run() .await; + + // rm -rf with non-matching glob expands to no args, so rm errors with "missing operand" + TestBuilder::new() + .file("keep.txt", "") + .command("rm -rf *.nonexistent") + .assert_stderr("rm: missing operand\n") + .assert_exit_code(1) + .assert_exists("keep.txt") + .run() + .await; + + // rm -rf with mix of matching and non-matching globs should remove matching files + // This is the fix for https://github.com/prefix-dev/pixi/issues/3969 + // The non-matching glob expands to nothing, but the matching glob still works + TestBuilder::new() + .directory("dist") + .file("dist/file.txt", "") + .command("rm -rf **/*.egg-info **/dist") + .assert_stderr("") + .assert_exit_code(0) + .assert_not_exists("dist") + .run() + .await; } // Basic integration tests as there are unit tests in the commands @@ -1381,15 +1404,19 @@ async fn glob_basic() { .run() .await; + // Non-matching globs expand to nothing (nullglob behavior) + // cat with no files reads from stdin (which is empty), so succeeds with no output TestBuilder::new() .file("test.txt", "test\n") .file("test2.txt", "test2\n") .command("cat *.ts") - .assert_stderr("glob: no matches found '$TEMP_DIR/*.ts'\n") - .assert_exit_code(1) + .assert_stderr("") + .assert_stdout("") + .assert_exit_code(0) .run() .await; + // Invalid glob pattern (empty brackets) still produces an error let mut builder = TestBuilder::new(); let temp_dir_path = builder.temp_dir_path(); let error_pos = temp_dir_path.to_string_lossy().len() + 1; @@ -1401,22 +1428,24 @@ async fn glob_basic() { .run() .await; + // Non-matching glob + || - since cat succeeds (reads empty stdin), || branch is not taken TestBuilder::new() .file("test.txt", "test\n") .file("test2.txt", "test2\n") .command("cat *.ts || echo 2") - .assert_stderr("glob: no matches found '$TEMP_DIR/*.ts'\n") - .assert_stdout("2\n") + .assert_stderr("") + .assert_stdout("") .assert_exit_code(0) .run() .await; + // Same with stderr redirect - cat still succeeds TestBuilder::new() .file("test.txt", "test\n") .file("test2.txt", "test2\n") .command("cat *.ts 2> /dev/null || echo 2") .assert_stderr("") - .assert_stdout("2\n") + .assert_stdout("") .assert_exit_code(0) .run() .await; From e41b8835fc73091f0ffddf614a68595bb1961de7 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Tue, 13 Jan 2026 10:11:17 +0100 Subject: [PATCH 2/5] keep literal pattern if no files match --- src/shell/execute.rs | 5 ++++- tests/integration_test.rs | 29 +++++++++++++++-------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/shell/execute.rs b/src/shell/execute.rs index c858fb5..f69207b 100644 --- a/src/shell/execute.rs +++ b/src/shell/execute.rs @@ -839,6 +839,8 @@ fn evaluate_word_parts( } let is_absolute = Path::new(¤t_text).is_absolute(); let cwd = state.cwd(); + // Save original text before potentially moving it into pattern + let original_text = current_text.clone(); let pattern = if is_absolute { current_text } else { @@ -860,7 +862,8 @@ fn evaluate_word_parts( let paths = paths.into_iter().filter_map(|p| p.ok()).collect::>(); if paths.is_empty() { - Ok(vec![]) + // No matches: return the literal pattern (bash default behavior) + Ok(vec![original_text.into()]) } else { let paths = if is_absolute { paths diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 6aad61b..a699c20 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1068,19 +1068,21 @@ async fn rm() { .run() .await; - // rm -rf with non-matching glob expands to no args, so rm errors with "missing operand" + // rm -rf with non-matching glob passes literal to rm, which silently ignores it with -f + // This is bash default behavior TestBuilder::new() .file("keep.txt", "") .command("rm -rf *.nonexistent") - .assert_stderr("rm: missing operand\n") - .assert_exit_code(1) + .assert_stderr("") + .assert_exit_code(0) .assert_exists("keep.txt") .run() .await; // rm -rf with mix of matching and non-matching globs should remove matching files // This is the fix for https://github.com/prefix-dev/pixi/issues/3969 - // The non-matching glob expands to nothing, but the matching glob still works + // Non-matching glob passes literal string, matching glob expands normally + // rm -f silently ignores the non-existent literal path TestBuilder::new() .directory("dist") .file("dist/file.txt", "") @@ -1404,15 +1406,14 @@ async fn glob_basic() { .run() .await; - // Non-matching globs expand to nothing (nullglob behavior) - // cat with no files reads from stdin (which is empty), so succeeds with no output + // Non-matching globs pass literal pattern to command (bash default behavior) + // cat receives literal "*.ts", fails with "No such file or directory" TestBuilder::new() .file("test.txt", "test\n") .file("test2.txt", "test2\n") .command("cat *.ts") - .assert_stderr("") - .assert_stdout("") - .assert_exit_code(0) + .assert_stderr("cat: *.ts: No such file or directory (os error 2)\n") + .assert_exit_code(1) .run() .await; @@ -1428,24 +1429,24 @@ async fn glob_basic() { .run() .await; - // Non-matching glob + || - since cat succeeds (reads empty stdin), || branch is not taken + // Non-matching glob + || - cat fails on literal pattern, so || branch runs TestBuilder::new() .file("test.txt", "test\n") .file("test2.txt", "test2\n") .command("cat *.ts || echo 2") - .assert_stderr("") - .assert_stdout("") + .assert_stderr("cat: *.ts: No such file or directory (os error 2)\n") + .assert_stdout("2\n") .assert_exit_code(0) .run() .await; - // Same with stderr redirect - cat still succeeds + // Same with stderr redirect - cat fails, || branch runs TestBuilder::new() .file("test.txt", "test\n") .file("test2.txt", "test2\n") .command("cat *.ts 2> /dev/null || echo 2") .assert_stderr("") - .assert_stdout("") + .assert_stdout("2\n") .assert_exit_code(0) .run() .await; From 062ad5a790b482755089e4367decc9c9d26fe425 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Tue, 13 Jan 2026 12:55:27 +0100 Subject: [PATCH 3/5] fix windows issue --- src/shell/commands/rm.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/shell/commands/rm.rs b/src/shell/commands/rm.rs index ca4777f..1b7d881 100644 --- a/src/shell/commands/rm.rs +++ b/src/shell/commands/rm.rs @@ -63,7 +63,7 @@ async fn execute_remove(cwd: &Path, args: &[OsString]) -> Result<()> { remove_file_or_dir(&path, &flags).await }; if let Err(err) = result - && (err.kind() != ErrorKind::NotFound || !flags.force) + && !(flags.force && should_ignore_error_with_force(&err)) { bail!( "cannot remove '{}': {}", @@ -76,6 +76,24 @@ async fn execute_remove(cwd: &Path, args: &[OsString]) -> Result<()> { Ok(()) } +/// Check if an error should be silently ignored when -f (force) flag is used. +/// This includes: +/// - NotFound: file doesn't exist +/// - On Windows: InvalidFilename (os error 123) - happens when literal glob +/// patterns like "*.nonexistent" are passed (since * is invalid in Windows filenames) +fn should_ignore_error_with_force(err: &std::io::Error) -> bool { + if err.kind() == ErrorKind::NotFound { + return true; + } + // On Windows, glob characters like * are invalid in filenames. + // When a non-matching glob is passed literally, Windows returns error 123. + #[cfg(windows)] + if err.raw_os_error() == Some(123) { + return true; + } + false +} + async fn remove_file_or_dir( path: &Path, flags: &RmFlags<'_>, From 68b6312b1ee73f7837e75b56ad2eade06f4f214f Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Wed, 14 Jan 2026 10:08:48 +0100 Subject: [PATCH 4/5] trigger ci From d5dc77b4814e6a1266e43eef86f1870778eb8677 Mon Sep 17 00:00:00 2001 From: David Sherret Date: Tue, 20 Jan 2026 11:09:31 -0500 Subject: [PATCH 5/5] add tests --- tests/integration_test.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/integration_test.rs b/tests/integration_test.rs index d48304c..362c86c 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1067,6 +1067,28 @@ async fn rm() { .assert_exit_code(1) .run() .await; + + // rm -f should ignore non-existent files + TestBuilder::new() + .command("rm -f nonexistent.txt") + .assert_exit_code(0) + .run() + .await; + + // rm -rf should ignore non-existent directories + TestBuilder::new() + .command("rm -rf nonexistent_dir") + .assert_exit_code(0) + .run() + .await; + + // rm -rf with glob pattern that matches nothing should succeed + // (when failglob is disabled, the pattern is passed literally to rm) + TestBuilder::new() + .command("shopt -u failglob && rm -rf *.nonexistent") + .assert_exit_code(0) + .run() + .await; } // Basic integration tests as there are unit tests in the commands