FileUtils #
Ruby has File and Dir for filesystem operations, but both often need lots of boilerplate code for common tasks like recursively copying directories, creating nested folder structures, or deleting directory trees. FileUtils fills this gap — it provides high-level filesystem operations inspired by Unix commands (cp, mv, rm, mkdir, chmod). A single line of FileUtils.cp_r(source, destination) replaces dozens of lines of manual recursive-copy code. This article covers the entire FileUtils API, the verbose and noop modes useful for debugging, and safe patterns for filesystem manipulation in Ruby scripts.
Getting Started with FileUtils #
FileUtils is available after require "fileutils". All methods are available as module methods — you can call them directly as FileUtils.method_name or include the module into a class.
require "fileutils"
# Direct usage as module methods
FileUtils.mkdir_p("/tmp/test/sub/dir")
FileUtils.touch("/tmp/test/file.txt")
# Or include into a class
class DeployScript
include FileUtils
def run
mkdir_p("releases/current")
cp_r("dist/.", "releases/current")
chmod_R(0755, "releases/current/bin")
end
end
# Verbose mode — print every executed command (like a shell with -v)
FileUtils.mkdir_p("/tmp/test", verbose: true)
# => mkdir -p /tmp/test
FileUtils.cp("a.txt", "b.txt", verbose: true)
# => cp a.txt b.txt
# Noop mode — simulate without real execution (dry run)
FileUtils.rm_rf("/tmp/important", noop: true, verbose: true)
# => rm -rf /tmp/important
# Nothing is actually deleted!
flowchart TD
A[FileUtils] --> B[Directory Operations]
A --> C[File Operations]
A --> D[Permission & Attributes]
A --> E[Special Modes]
B --> B1[mkdir / mkdir_p]
B --> B2[rmdir / rm_rf]
B --> B3[cd / pwd]
C --> C1[cp / cp_r]
C --> C2[mv]
C --> C3[rm / rm_f / rm_rf]
C --> C4[touch / install]
C --> C5[ln / ln_s / ln_sf]
D --> D1[chmod / chmod_R]
D --> D2[chown / chown_R]
E --> E1[verbose: true]
E --> E2[noop: true]
E --> E3[FileUtils::Verbose]
E --> E4[FileUtils::NoWrite]
E --> E5[FileUtils::DryRun]Directory Operations #
Creating Directories #
require "fileutils"
# mkdir — create a single directory (parent must exist)
FileUtils.mkdir("/tmp/one_level")
# mkdir_p — create a directory with all missing parents
# This is the most frequently used one
FileUtils.mkdir_p("/tmp/level1/level2/level3")
# Creates /tmp/level1, /tmp/level1/level2, and /tmp/level1/level2/level3
# Create several directories at once
FileUtils.mkdir_p(["log", "tmp/pids", "tmp/sockets", "public/uploads"])
# mkdir_p doesn't error if the directory already exists — safe to call repeatedly
FileUtils.mkdir_p("/tmp/level1/level2/level3") # no error!
# rmdir — delete an empty directory
FileUtils.rmdir("/tmp/empty") # errors if not empty
# Recursively removing empty directories from the leaves up
# (rarely used, usually rm_rf is enough)
FileUtils.remove_dir("/tmp/level1")
Changing Directories #
# cd — change the working directory for the duration of a block
FileUtils.cd("/tmp") do
# the working directory is /tmp during this block
FileUtils.touch("file_in_tmp.txt")
puts Dir.pwd # => /tmp
end
# After the block finishes, back to the original working directory
# cd without a block — permanently change the working directory
# Careful: this changes the process's global state
FileUtils.cd("/tmp")
puts Dir.pwd # => /tmp
Copying Files and Directories #
cp — Copying Files #
require "fileutils"
# cp — copy a single file
FileUtils.cp("source.txt", "destination.txt")
FileUtils.cp("source.txt", "/tmp/") # copy to a directory, same name
# Copy several files to a destination directory
FileUtils.cp(["a.txt", "b.txt", "c.txt"], "/tmp/backup/")
# cp with preserve — keep timestamps and permissions
FileUtils.cp("source.txt", "destination.txt", preserve: true)
# cp_lr — copy with hard links (faster, saves disk space)
# The source and destination files share the same inode
FileUtils.cp_lr("source.txt", "link.txt")
cp_r — Recursive Copy #
cp_r is the most frequently used method for copying a directory with all its contents — files, subdirectories, and sub-subdirectories.
# cp_r — recursively copy a directory
FileUtils.cp_r("source_directory", "destination_directory")
# If the destination doesn't exist, it's created with that name
# If it already exists, the source is copied into it
# Copy a directory's contents (with a trailing slash or /.)
FileUtils.cp_r("dist/.", "public/") # copy dist's contents into public
# Deploy example: copy a build output to a release directory
def create_release(version)
release_dir = "releases/#{version}"
FileUtils.mkdir_p(release_dir)
FileUtils.cp_r("dist/.", release_dir)
FileUtils.cp("config/production.yml", "#{release_dir}/config/")
release_dir
end
# cp_r with an exclusion list — no built-in option, needs manual work
def cp_r_except(source, destination, excludes: [])
FileUtils.mkdir_p(destination)
Pathname.new(source).glob("**/*").each do |src_path|
next if excludes.any? { |pattern| src_path.to_s.match?(pattern) }
next if src_path.directory?
relative = src_path.relative_path_from(source)
dst_path = Pathname.new(destination) / relative
FileUtils.mkdir_p(dst_path.dirname)
FileUtils.cp(src_path.to_s, dst_path.to_s)
end
end
cp_r_except("project", "backup",
excludes: [/\.git/, /node_modules/, /\.DS_Store/])
Moving and Renaming Files #
require "fileutils"
# mv — move or rename files/directories
FileUtils.mv("old.txt", "new.txt") # rename in place
FileUtils.mv("file.txt", "/tmp/") # move to another directory
FileUtils.mv("file.txt", "/tmp/new_name.txt") # move and rename at once
# mv several files to a destination directory
FileUtils.mv(["a.txt", "b.txt"], "/tmp/archive/")
# Pattern: backup before replace
def replace_with_backup(original_file, new_file)
backup = "#{original_file}.bak"
FileUtils.cp(original_file, backup) if File.exist?(original_file)
FileUtils.mv(new_file, original_file)
backup
end
# Safe mv with an existence check
def move_if_exists(source, destination)
if File.exist?(source)
FileUtils.mkdir_p(File.dirname(destination))
FileUtils.mv(source, destination)
true
else
false
end
end
Deleting Files and Directories #
Deletion is the operation requiring the most care — there’s no recycle bin in the filesystem!
require "fileutils"
# rm — delete a file (errors if missing)
FileUtils.rm("file.txt")
# rm_f — delete a file, ignore if missing (force)
FileUtils.rm_f("maybe_missing.txt")
# rm with several files at once
FileUtils.rm(["a.txt", "b.txt", "c.txt"])
FileUtils.rm_f(["a.tmp", "b.tmp"])
# rm_r — recursively delete a directory (errors if missing)
FileUtils.rm_r("old_directory")
# rm_rf — recursively delete a directory, ignore if missing (most used)
FileUtils.rm_rf("old_directory")
FileUtils.rm_rf("/tmp/build_output")
rm_rfcan’t be undone. Files deleted withrm_rfdon’t go to a recycle bin — they’re gone from the disk immediately. Always validate the path before runningrm_rf, especially if the path comes from a variable or user input.# ANTI-PATTERN: deleting directly without validation def clean_build(build_dir) FileUtils.rm_rf(build_dir) # dangerous if build_dir is "/" or "" end # CORRECT: validate the path before deleting def clean_build(build_dir) path = Pathname.new(build_dir).expand_path # Make sure we're not deleting root or home raise "Path too short, probably wrong!" if path.to_s.split("/").length < 3 raise "Must not delete the home directory!" if path.to_s.start_with?(Dir.home) FileUtils.rm_rf(path.to_s) if path.directory? end
# Safe pattern: preview with noop, execute after confirmation
def delete_with_confirmation(path)
puts "Will delete:"
FileUtils.rm_rf(path, noop: true, verbose: true)
print "Continue? (y/n): "
return unless gets.chomp.downcase == "y"
FileUtils.rm_rf(path)
puts "Done."
end
Symlinks #
require "fileutils"
# ln — hard link (files only, not directories)
FileUtils.ln("target.txt", "link.txt")
# ln_s — symbolic link (symlink)
FileUtils.ln_s("target.txt", "link.txt")
FileUtils.ln_s("/absolute/path/target", "link")
# ln_sf — symbolic link with force (overwrite if it exists)
FileUtils.ln_sf("new_target.txt", "link.txt") # update an existing symlink
# Deploy pattern: a current symlink to the latest release
def update_current_symlink(release_path)
current = "releases/current"
FileUtils.rm(current) if File.symlink?(current)
FileUtils.ln_s(File.expand_path(release_path), current)
end
Permissions and Ownership #
require "fileutils"
# chmod — change permissions of one file/directory
FileUtils.chmod(0644, "file.txt") # rw-r--r--
FileUtils.chmod(0755, "script.sh") # rwxr-xr-x
FileUtils.chmod(0600, "private.key") # rw-------
# chmod several files at once
FileUtils.chmod(0644, ["a.txt", "b.txt", "c.txt"])
# chmod_R — recursively change permissions
FileUtils.chmod_R(0755, "bin/") # everything in bin/ becomes executable
FileUtils.chmod_R(0644, "public/") # all public files world-readable
# chown — change ownership (requires privileges)
FileUtils.chown("www-data", "www-data", "public/")
# chown_R — recursively change ownership
FileUtils.chown_R("deploy", "deploy", "releases/")
# Common post-deploy pattern
def set_production_permissions(app_dir)
# Directories: 755, files: 644
Find.find(app_dir) do |path|
if File.directory?(path)
FileUtils.chmod(0755, path)
else
FileUtils.chmod(0644, path)
end
end
# Special scripts: 755
FileUtils.chmod_R(0755, File.join(app_dir, "bin"))
end
touch and install #
require "fileutils"
# touch — create an empty file or update the timestamp (like Unix touch)
FileUtils.touch("new_file.txt") # create an empty file
FileUtils.touch("existing.txt") # update mtime to now
FileUtils.touch(["a.txt", "b.txt"]) # several files at once
# install — copy while setting permissions
# Very useful for deployment scripts
FileUtils.install("build/app", "/usr/local/bin/app", mode: 0755)
FileUtils.install("config/app.conf", "/etc/app/app.conf", mode: 0644)
Verbose and Dry-Run Modes #
One of FileUtils’ often-overlooked features is built-in support for debugging and dry runs — you can see what will be done without actually doing anything.
require "fileutils"
# verbose: true — print every operation to STDOUT
FileUtils.cp_r("src/", "dst/", verbose: true)
# => cp -r src/ dst/
FileUtils.rm_rf("old_build/", verbose: true)
# => rm -rf old_build/
# noop: true — don't execute, just simulate
FileUtils.rm_rf("/tmp/important", noop: true, verbose: true)
# => rm -rf /tmp/important
# (nothing is actually deleted)
# Combining both = an informative dry run
def deploy(env)
dry_run = env != "production"
opts = { verbose: true, noop: dry_run }
FileUtils.mkdir_p("releases/#{version}", **opts)
FileUtils.cp_r("dist/.", "releases/#{version}", **opts)
FileUtils.ln_sf("releases/#{version}", "current", **opts)
if dry_run
puts "[DRY RUN] No real changes"
else
puts "Deploy done!"
end
end
# Special modules for different modes
# FileUtils::Verbose — all operations verbose by default
# FileUtils::NoWrite — all operations noop by default
# FileUtils::DryRun — verbose + noop
class DeployDryRun
include FileUtils::DryRun # all operations are dry run + verbose
def run
mkdir_p("releases/latest")
cp_r("dist/.", "releases/latest")
# All of this is only printed, not executed
end
end
Real-World Usage Patterns #
A Simple Deploy Script #
require "fileutils"
require "pathname"
class Deployer
RELEASE_DIR = Pathname.new("releases")
CURRENT_LINK = Pathname.new("current")
MAX_RELEASES = 5
def initialize(build_dir, verbose: false)
@build = Pathname.new(build_dir)
@verbose = verbose
@opts = { verbose: @verbose }
end
def deploy
version = Time.now.strftime("%Y%m%d%H%M%S")
release_path = RELEASE_DIR / version
puts "Deploying version #{version}..."
create_release(release_path)
update_symlink(release_path)
clean_old_releases
puts "Deploy successful: #{release_path}"
release_path
end
private
def create_release(path)
FileUtils.mkdir_p(path.to_s, **@opts)
FileUtils.cp_r("#{@build}/.", path.to_s, **@opts)
FileUtils.chmod_R(0755, (path / "bin").to_s, **@opts) if (path / "bin").directory?
end
def update_symlink(release_path)
FileUtils.rm(CURRENT_LINK.to_s, **@opts) if CURRENT_LINK.symlink?
FileUtils.ln_s(release_path.expand_path.to_s, CURRENT_LINK.to_s, **@opts)
end
def clean_old_releases
all_releases = RELEASE_DIR.children.select(&:directory?).sort
old_releases = all_releases.first([all_releases.length - MAX_RELEASES, 0].max)
old_releases.each do |r|
puts "Deleting old release: #{r}"
FileUtils.rm_rf(r.to_s, **@opts)
end
end
end
# Usage
deployer = Deployer.new("build/", verbose: true)
deployer.deploy
Directory Synchronization #
require "fileutils"
require "pathname"
require "digest"
# Simple sync: copy changed files, delete ones that no longer exist
def synchronize(source_dir, destination_dir, verbose: false)
source = Pathname.new(source_dir)
destination = Pathname.new(destination_dir)
FileUtils.mkdir_p(destination.to_s)
added = 0
updated = 0
deleted = 0
# Copy new or changed files
source.glob("**/*").select(&:file?).each do |src|
rel = src.relative_path_from(source)
dst = destination / rel
if !dst.exist?
FileUtils.mkdir_p(dst.dirname.to_s)
FileUtils.cp(src.to_s, dst.to_s, verbose: verbose)
added += 1
elsif Digest::MD5.file(src) != Digest::MD5.file(dst)
FileUtils.cp(src.to_s, dst.to_s, verbose: verbose)
updated += 1
end
end
# Delete files in the destination that don't exist in the source
destination.glob("**/*").select(&:file?).each do |dst|
rel = dst.relative_path_from(destination)
src = source / rel
unless src.exist?
FileUtils.rm(dst.to_s, verbose: verbose)
deleted += 1
end
end
{ added: added, updated: updated, deleted: deleted }
end
result = synchronize("src/", "backup/", verbose: true)
puts "Sync complete: +#{result[:added]} ~#{result[:updated]} -#{result[:deleted]}"
FileUtils vs Alternatives #
| Need | FileUtils | Pathname | Shell (backtick) |
|---|---|---|---|
| Recursive copy | cp_r | manual needed | `cp -r src dst` |
| Create dir + parents | mkdir_p | mkpath | `mkdir -p dir` |
| Recursive delete | rm_rf | rmtree | `rm -rf dir` |
| Move/rename | mv | rename (one fs) | `mv src dst` |
| Recursive permissions | chmod_R | none | `chmod -R 755 dir` |
| Dry run | noop: true | none | none |
| Cross-platform | ✓ | ✓ | ✗ Windows |
| Error handling | Exception | Exception | Return code |
# ANTI-PATTERN: using shell commands from Ruby
system("cp -r src/ dst/") # ✗ not cross-platform, no error handling
`rm -rf #{user_input}` # ✗ shell injection vulnerability!
system("mkdir -p #{dir}") # ✗ can be shell-injected
# CORRECT: use FileUtils
FileUtils.cp_r("src/", "dst/") # ✓ cross-platform, raises exceptions on errors
FileUtils.rm_rf(sanitized_dir) # ✓ injection-safe
FileUtils.mkdir_p(dir) # ✓ proper error handling
Summary #
mkdir_pfor creating directories — always usemkdir_pnotmkdir; it creates all missing parents and doesn’t error if the directory already exists.cp_rfor copying directories — copies a directory with all its contents recursively; use"src/."as the source to copy the contents without creating a new subdirectory.rm_rfmust be validated — always make sure the path to be deleted is correct before callingrm_rf; a wrong delete can’t be undone.verbose: trueandnoop: true— use for debugging and dry runs; combine both to see what will happen before actually executing.- Don’t use shell commands — backticks (
`) andsystem()for file operations are vulnerable to shell injection and aren’t cross-platform; FileUtils is always safer.FileUtils::DryRunfor testing scripts — include this module into your deploy/script classes during development so all operations are dry runs by default.- Combine with Pathname — use Pathname to build and manipulate paths, then pass
.to_sto FileUtils for the actual operations.installfor deployments —FileUtils.installis more expressive than separatecp + chmodwhen you need to copy a file while setting its permissions.