From: "nobu (Nobuyoshi Nakada) via ruby-core" Date: 2026-09-14T05:30:29+00:00 Subject: [ruby-core:126687] [Ruby Feature#22312] Add parent directory operations and recursive file removal Issue #22312 has been reported by nobu (Nobuyoshi Nakada). ---------------------------------------- Feature #22312: Add parent directory operations and recursive file removal https://bugs.ruby-lang.org/issues/22312 * Author: nobu (Nobuyoshi Nakada) * Status: Open ---------------------------------------- I propose extending `Dir.mkdir`, `Dir.rmdir`, and `File.unlink` with options for operating on directory trees. These operations currently require `FileUtils`; providing them in core would also allow recursive removal to improve robustness by using native directory descriptors or Windows handles where available. ## Proposed API ````ruby Dir.mkdir(path, permissions = 0777, perm: permissions, parents: false) Dir.rmdir(path, parents: false, ignore_non_empty: false) File.unlink(*paths, recursive: false) ```` The aliases `Dir.delete`, `Dir.unlink`, and `File.delete` receive the corresponding options. Calls without the new options retain their existing behavior. ## Creating parent directories `Dir.mkdir(path, parents: true)` creates missing parent directories and returns `0` if the target is already a directory. An existing non-directory raises an exception. ````ruby Dir.mkdir("build/cache/objects", parents: true, perm: 0700) # => 0 ```` The requested permissions apply only to the final directory. New parents use `0777`, subject to the process umask, and existing directories keep their permissions. Permissions remain ignored on Windows. ## Removing empty parent directories `Dir.rmdir(path, parents: true)` removes the target and then successive empty parents. It does not remove a filesystem root or the relative parent path `.`. Its return value remains `0`. The `ignore_non_empty:` option controls when removal stops successfully: | Value | Target directory | Parent directories | |-------------------|--------------------------------|-----------------------------------------------------| | `false` (default) | Raise if non-empty | Raise if non-empty | | `true` | Stop successfully if non-empty | Stop successfully if non-empty | | `:parents` | Require successful removal | Stop successfully if non-empty or no longer present | Other errors raise exceptions. `:parents` matches the error handling of `FileUtils.rmdir(path, parents: true)` and has no effect without `parents: true`. Removal already completed is not rolled back on an error. ````ruby Dir.rmdir("build/cache/objects", parents: true, ignore_non_empty: :parents) ```` Parent removal currently uses path-based calls to `rmdir`, resolving the path again at each step. It does not guarantee protection against concurrent renames or symbolic-link substitution in intermediate path components. Such changes can redirect removal to unintended empty directories that the process has permission to remove. Avoid using this option where another process can modify the path concurrently. Retaining descriptors for all ancestors would require a number of descriptors proportional to the path depth. Walking upward with `openat(fd, "..")` would bound descriptor usage, but could follow a directory into a different parent after a concurrent rename. The current proposal retains path-based parent removal with the limitation described above. ## Recursive removal `File.unlink(*paths, recursive: true)` also removes directories and their contents. It returns the number of supplied paths, rather than the number of entries removed. ````ruby File.unlink("build/cache", recursive: true) # => 1 ```` Symbolic links at the target, including targets specified with trailing slashes, and links inside the tree are removed without traversing their targets in the absence of concurrent replacement. Filesystem roots and paths ending in `.` or `..` are rejected. Like `FileUtils.rm_r` with its default `force: false`, this option does not suppress errors: missing paths and other failures raise exceptions, possibly after partial removal. The implementation uses descriptor-relative operations, including `openat` and `unlinkat`, on platforms supporting them. Opened directories remain accessible through their descriptors if renamed during traversal, so their contents may still be removed after a rename. Concurrent modifications can cause an exception; the operation is not atomic. Mounted filesystems inside the tree are traversed on non-Windows platforms. On Windows, the implementation retains directory handles with sharing disabled and removes entries through their handles. Reparse points in the ancestor path are rejected; reparse points at or inside the target are removed without traversal. Existing handles that conflict with exclusive access cause an error. On other platforms, a path-based fallback behaves similarly to `FileUtils.remove_entry`. It cannot prevent traversal through a symbolic link substituted concurrently for a directory, so it is unsuitable for trees another process can modify concurrently. ## Possible `force:` option An additional `force: false` keyword for `File.unlink` may be useful for cleanup operations. Combined with `recursive: true`, it could cover use cases currently served by `FileUtils.rm_rf`. This option is not implemented in the current patch. The scope of error suppression needs discussion: should `force: true` ignore only missing paths, or suppress removal errors more broadly as `FileUtils.rm_r(force: true)` does? Its applicability without `recursive: true` and the return value when some supplied paths cannot be removed also need to be defined. ## Implementation [Implementation branch](https://github.com/nobu/ruby/tree/recursive-file-operations) -- https://bugs.ruby-lang.org/ ______________________________________________ ruby-core mailing list -- ruby-core@ml.ruby-lang.org To unsubscribe send an email to ruby-core-leave@ml.ruby-lang.org ruby-core info -- https://ml.ruby-lang.org/mailman3/lists/ruby-core.ml.ruby-lang.org/