From 250389d6e56560d3fc7d44492cb23cb763c0ec93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Sun, 26 Apr 2026 09:31:18 +0200 Subject: [PATCH 1/2] Add each_directory to File.rm_rf, closes #15308 --- lib/elixir/lib/file.ex | 29 +++++++++++++------- lib/elixir/test/elixir/file_test.exs | 40 ++++++++++++++++++++++++++++ lib/mix/test/mix/release_test.exs | 2 +- lib/mix/test/test_helper.exs | 2 +- 4 files changed, 62 insertions(+), 11 deletions(-) diff --git a/lib/elixir/lib/file.ex b/lib/elixir/lib/file.ex index 183221b9038..2147f388951 100644 --- a/lib/elixir/lib/file.ex +++ b/lib/elixir/lib/file.ex @@ -1619,6 +1619,13 @@ defmodule File do directories removed in no specific order, `{:error, reason, file}` otherwise. + ## Options + + * `:each_directory` - (since v1.20.0) a callback invoked for each + directory before its contents are deleted. The callback receives the + directory path as a binary. It is useful, for example, to grant write + permission to a directory before attempting to delete it. + ## Examples File.rm_rf("samples") @@ -1630,24 +1637,28 @@ defmodule File do File.rm_rf("/tmp") #=> {:error, :eperm, "/tmp"} """ - @spec rm_rf(Path.t()) :: {:ok, [binary]} | {:error, posix | :badarg, binary} - def rm_rf(path) do + @spec rm_rf(Path.t(), each_directory: (Path.t() -> term)) :: + {:ok, [binary]} | {:error, posix | :badarg, binary} + def rm_rf(path, options \\ []) do {major, _} = :os.type() + each_directory = Keyword.get(options, :each_directory, fn _ -> :ok end) path |> IO.chardata_to_string() |> assert_no_null_byte!("File.rm_rf/1") - |> do_rm_rf([], major) + |> do_rm_rf([], major, each_directory) end - defp do_rm_rf(path, acc, major) do + defp do_rm_rf(path, acc, major, each_directory) do case safe_list_dir(path, major) do {:ok, files} when is_list(files) -> + each_directory.(path) + acc = Enum.reduce(files, acc, fn file, acc -> # In case we can't delete, continue anyway, we might succeed # to delete it on Windows due to how they handle symlinks. - case do_rm_rf(Path.join(path, file), acc, major) do + case do_rm_rf(Path.join(path, file), acc, major, each_directory) do {:ok, acc} -> acc {:error, _, _} -> acc end @@ -1723,7 +1734,7 @@ defmodule File do end @doc """ - Same as `rm_rf/1` but raises a `File.Error` exception in case of failures, + Same as `rm_rf/2` but raises a `File.Error` exception in case of failures, otherwise returns the list of files or directories removed. ## Examples @@ -1737,9 +1748,9 @@ defmodule File do File.rm_rf!("/tmp") ** (File.Error) could not remove files and directories recursively from "/tmp": not owner """ - @spec rm_rf!(Path.t()) :: [binary] - def rm_rf!(path) do - case rm_rf(path) do + @spec rm_rf!(Path.t(), each_directory: (Path.t() -> term)) :: [binary] + def rm_rf!(path, options \\ []) do + case rm_rf(path, options) do {:ok, files} -> files diff --git a/lib/elixir/test/elixir/file_test.exs b/lib/elixir/test/elixir/file_test.exs index 3e17cf05d30..ace8f62db72 100644 --- a/lib/elixir/test/elixir/file_test.exs +++ b/lib/elixir/test/elixir/file_test.exs @@ -1473,6 +1473,46 @@ defmodule FileTest do assert File.rm_rf(dir) == {:ok, [dir, subdir]} end + test "rm_rf with each_directory callback" do + fixture = tmp_path("tmp") + File.mkdir(fixture) + File.cp_r!(fixture_path("cp_r"), fixture) + me = self() + + {:ok, files} = + File.rm_rf(fixture, each_directory: fn path -> send(me, {:dir, path}) end) + + assert length(files) == 7 + + assert_received {:dir, ^fixture} + assert_received {:dir, _} + assert_received {:dir, _} + end + + @tag :unix + test "rm_rf with each_directory callback for read-only dir" do + dir = tmp_path("tmp") + subdir = Path.join(dir, "read-only") + File.mkdir_p!(subdir) + File.write!(Path.join(subdir, "file.txt"), "hello") + File.chmod!(subdir, 0o444) + + me = self() + + {:ok, files} = + File.rm_rf(dir, + each_directory: fn path -> + send(me, {:dir, path}) + File.chmod(path, 0o755) + end + ) + + assert length(files) == 3 + refute File.exists?(dir) + assert_received {:dir, ^dir} + assert_received {:dir, ^subdir} + end + test "rm_rf with unknown" do fixture = tmp_path("tmp.unknown") assert File.rm_rf(fixture) == {:ok, []} diff --git a/lib/mix/test/mix/release_test.exs b/lib/mix/test/mix/release_test.exs index 39835dc351d..61f5147e981 100644 --- a/lib/mix/test/mix/release_test.exs +++ b/lib/mix/test/mix/release_test.exs @@ -27,7 +27,7 @@ defmodule Mix.ReleaseTest do end setup do - File.rm_rf!(tmp_path("mix_release")) + File.rm_rf!(tmp_path("mix_release"), each_directory: &File.chmod(&1, 0o755)) File.mkdir_p!(tmp_path("mix_release")) :ok end diff --git a/lib/mix/test/test_helper.exs b/lib/mix/test/test_helper.exs index faba7d07f7d..559261dbf8b 100644 --- a/lib/mix/test/test_helper.exs +++ b/lib/mix/test/test_helper.exs @@ -175,7 +175,7 @@ defmodule MixTest.Case do dest = tmp_path(String.replace(tmp, ":", "_")) flag = String.to_charlist(tmp_path()) - File.rm_rf!(dest) + File.rm_rf!(dest, each_directory: &File.chmod(&1, 0o755))) File.mkdir_p!(dest) File.cp_r!(src, dest) From a71df091d3b4eea5b26833584b36524d561b5d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Sun, 26 Apr 2026 09:34:52 +0200 Subject: [PATCH 2/2] Remove extra ) --- lib/mix/test/test_helper.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/test/test_helper.exs b/lib/mix/test/test_helper.exs index 559261dbf8b..2bc4026b208 100644 --- a/lib/mix/test/test_helper.exs +++ b/lib/mix/test/test_helper.exs @@ -175,7 +175,7 @@ defmodule MixTest.Case do dest = tmp_path(String.replace(tmp, ":", "_")) flag = String.to_charlist(tmp_path()) - File.rm_rf!(dest, each_directory: &File.chmod(&1, 0o755))) + File.rm_rf!(dest, each_directory: &File.chmod(&1, 0o755)) File.mkdir_p!(dest) File.cp_r!(src, dest)