Disk Usage Reporting and Cleanup Automation in Ruby

DevOps / System Administration · Linux · Ruby 3.x

The problem this solves

“The disk is full” is one of the most common pages a sysadmin gets, and answering it usually means SSHing in and running a slow, manual chain of du -sh */, find . -mtime +30, and squinting at the output. This tutorial builds a single-pass Ruby scanner that answers three questions at once: which directories are actually consuming the space, which individual files are the largest, and which files haven’t been touched in a long time and are therefore safe cleanup candidates — with an optional, safety-first cleanup mode that defaults to a dry run.

Prerequisites

  • Ruby 2.7+ (tested on Ruby 3.0.2)
  • No external gems — only Ruby standard library (find, fileutils, optparse)
  • Read access to the directory tree you want to scan; write/delete access only needed if you use --clean --yes

How it works

Ruby disk usage reporter flow: Find.find single tree walk to per-file size and mtime to largest dirs and files report and StaleFileFinder to Cleaner

  • DiskScanner — walks the tree exactly once with Find.find, recording each file’s size and modification time, and rolling sizes up into every ancestor directory as it goes
  • HumanSize — formats raw byte counts the way du -h does (B/KB/MB/GB/TB)
  • ReportPrinter — renders the largest-directories, largest-files, and stale-files sections from the single scan result
  • StaleFileFinder — filters files by age and an optional filename glob (e.g. *.log)
  • Cleaner — deletes stale files, defaulting to a dry run unless --yes is explicitly passed

The complete code

Save this as disk_report.rb:

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# disk_report.rb
#
# Scans a directory tree and reports where disk space is actually going:
# the largest subdirectories, the largest individual files, and files that
# haven't been touched in a long time (prime cleanup candidates). Optionally
# deletes stale files matching a pattern, safely, with a dry-run by default.
#
# Usage:
#   ruby disk_report.rb /var/log
#   ruby disk_report.rb /var/log --top 20
#   ruby disk_report.rb /var/log --stale-days 30 --pattern "*.log"
#   ruby disk_report.rb /var/log --stale-days 30 --pattern "*.gz" --clean --yes

require 'optparse'
require 'find'
require 'fileutils'

# Walks a directory tree once, collecting per-file size/mtime and rolling
# those up into per-directory totals. A single Find.find pass keeps this
# fast even on large trees, instead of re-scanning per report section.
class DiskScanner
  FileRecord = Struct.new(:path, :size, :mtime, keyword_init: true)

  def initialize(root)
    @root = File.expand_path(root)
  end

  # Returns [files, dir_sizes] where files is an array of FileRecord and
  # dir_sizes is a Hash of directory path => cumulative size in bytes.
  def scan
    files = []
    dir_sizes = Hash.new(0)

    Find.find(@root) do |path|
      if File.symlink?(path)
        Find.prune # never follow symlinks - avoids double-counting and loops
        next
      end

      if File.directory?(path)
        next
      end

      begin
        stat = File.stat(path)
      rescue Errno::ENOENT, Errno::EACCES
        next # file vanished mid-scan or isn't readable - skip, don't crash
      end

      files << FileRecord.new(path: path, size: stat.size, mtime: stat.mtime)
      attribute_to_ancestors(dir_sizes, path, stat.size)
    end

    [files, dir_sizes]
  end

  private

  # Adds this file's size to every ancestor directory up to (and including)
  # the scan root, so "largest directories" reflects true recursive totals.
  def attribute_to_ancestors(dir_sizes, path, size)
    dir = File.dirname(path)
    loop do
      dir_sizes[dir] += size
      break if dir == @root || dir == '/' || dir == '.'

      parent = File.dirname(dir)
      break if parent == dir

      dir = parent
    end
  end
end

# Formats byte counts the way a human reads a `du -h` report.
module HumanSize
  UNITS = %w[B KB MB GB TB].freeze

  def self.format(bytes)
    size = bytes.to_f
    UNITS.each_with_index do |unit, i|
      return sprintf('%.1f %s', size, unit) if size < 1024 || i == UNITS.size - 1

      size /= 1024
    end
  end
end

# Renders the largest-directories / largest-files / stale-files sections.
class ReportPrinter
  def initialize(files, dir_sizes, top:)
    @files = files
    @dir_sizes = dir_sizes
    @top = top
  end

  def print_summary(root)
    total = @files.sum(&:size)
    puts "Disk usage report for #{root}"
    puts "Total: #{HumanSize.format(total)} across #{@files.size} files\n\n"
  end

  def print_largest_dirs
    puts "Top #{@top} largest directories:"
    @dir_sizes.sort_by { |_dir, size| -size }.first(@top).each do |dir, size|
      puts "  #{HumanSize.format(size).rjust(10)}  #{dir}"
    end
    puts
  end

  def print_largest_files
    puts "Top #{@top} largest files:"
    @files.sort_by { |f| -f.size }.first(@top).each do |f|
      puts "  #{HumanSize.format(f.size).rjust(10)}  #{f.path}"
    end
    puts
  end

  def print_stale(stale_files)
    return if stale_files.empty?

    puts "Files not modified in the last #{stale_files.first[:days]} days:"
    stale_files.each do |entry|
      f = entry[:file]
      puts "  #{HumanSize.format(f.size).rjust(10)}  #{f.mtime.strftime('%Y-%m-%d')}  #{f.path}"
    end
    puts
  end
end

# Finds files matching a glob-style pattern that haven't been modified in
# at least stale_days days.
class StaleFileFinder
  def initialize(files, stale_days:, pattern: '*')
    @files = files
    @stale_days = stale_days
    @pattern = pattern
    @cutoff = Time.now - (stale_days * 24 * 60 * 60)
  end

  def find
    @files.select { |f| f.mtime < @cutoff && File.fnmatch(@pattern, File.basename(f.path)) }
          .map { |f| { file: f, days: @stale_days } }
  end
end

# Deletes a set of files, refusing to do so unless explicitly confirmed,
# and reports how much space was reclaimed.
class Cleaner
  def initialize(stale_entries, dry_run: true)
    @stale_entries = stale_entries
    @dry_run = dry_run
  end

  def run
    reclaimed = 0
    @stale_entries.each do |entry|
      f = entry[:file]
      if @dry_run
        puts "[dry-run] would delete #{f.path} (#{HumanSize.format(f.size)})"
      else
        begin
          FileUtils.rm_f(f.path)
          puts "deleted #{f.path} (#{HumanSize.format(f.size)})"
        rescue Errno::EACCES => e
          warn "could not delete #{f.path}: #{e.message}"
          next
        end
      end
      reclaimed += f.size
    end
    puts "\n#{@dry_run ? 'Would reclaim' : 'Reclaimed'}: #{HumanSize.format(reclaimed)}"
  end
end

if __FILE__ == $PROGRAM_NAME
  options = { top: 10, stale_days: nil, pattern: '*', clean: false, yes: false }

  OptionParser.new do |opts|
    opts.banner = 'Usage: disk_report.rb ROOT_DIR [options]'
    opts.on('--top N', Integer, 'How many largest dirs/files to show (default 10)') { |v| options[:top] = v }
    opts.on('--stale-days N', Integer, 'Flag files not modified in N days') { |v| options[:stale_days] = v }
    opts.on('--pattern GLOB', 'Filename glob for staleness/cleanup, e.g. "*.log" (default *)') { |v| options[:pattern] = v }
    opts.on('--clean', 'Delete stale files matching --pattern (requires --stale-days)') { options[:clean] = true }
    opts.on('--yes', 'Actually perform deletion instead of a dry-run') { options[:yes] = true }
  end.parse!

  root = ARGV[0]
  if root.nil? || !Dir.exist?(root)
    warn 'Error: please provide a valid directory to scan.'
    exit 1
  end

  files, dir_sizes = DiskScanner.new(root).scan
  printer = ReportPrinter.new(files, dir_sizes, top: options[:top])
  printer.print_summary(root)
  printer.print_largest_dirs
  printer.print_largest_files

  if options[:stale_days]
    stale = StaleFileFinder.new(files, stale_days: options[:stale_days], pattern: options[:pattern]).find
    printer.print_stale(stale)

    if options[:clean]
      Cleaner.new(stale, dry_run: !options[:yes]).run
    end
  end
end

Step-by-step walkthrough

1. One scan, not three

DiskScanner#scan uses Find.find to walk the tree exactly once, recording a FileRecord (path, size, mtime) for every file and simultaneously accumulating that size into every ancestor directory via attribute_to_ancestors. This single pass is what feeds the largest-directories report, the largest-files report, and the stale-file search — there’s no need to re-walk the filesystem for each section, which matters a lot on trees with hundreds of thousands of files.

2. Handling the edge cases that crash naive scanners

Two defensive touches keep this reliable on real production filesystems: symlinks are explicitly skipped with Find.prune (following them risks double-counting shared space or looping forever on a circular symlink), and a rescue Errno::ENOENT, Errno::EACCES around File.stat means a file that disappears mid-scan or that the running user can’t read is silently skipped rather than crashing the whole report.

3. Reading the report

Example largest-directories report shown as a horizontal bar chart

The “largest directories” section is sorted by cumulative recursive size, largest first — exactly the “where did my disk go” answer du -sh * | sort -rh gives you, but computed in one pass alongside the file-level and staleness data.

4. Cleanup that defaults to safe

StaleFileFinder filters by both age (--stale-days) and an optional filename glob (--pattern "*.log"), so you can target exactly the file types you mean to clean up. Cleaner then defaults to dry_run: true — you have to pass --yes explicitly to actually delete anything, and every deletion (real or simulated) reports the space it reclaims.

Example output

Real output from running against a test directory containing a mix of recent and old files:

$ ruby disk_report.rb disktest --top 5
Disk usage report for disktest
Total: 5.2 MB across 5 files

Top 5 largest directories:
      5.2 MB  disktest
      2.9 MB  disktest/data
      1.6 MB  disktest/logs
    781.2 KB  disktest/cache

Top 5 largest files:
      2.9 MB  disktest/data/dataset.csv
      1.1 MB  disktest/logs/error.log
    781.2 KB  disktest/cache/old_cache.tmp
    488.3 KB  disktest/logs/app.log
     200.0 B  disktest/data/small.txt

$ ruby disk_report.rb disktest --stale-days 30 --pattern "*.tmp" --clean
...
Files not modified in the last 30 days:
    781.2 KB  2026-05-04  disktest/cache/old_cache.tmp

[dry-run] would delete disktest/cache/old_cache.tmp (781.2 KB)

Would reclaim: 781.2 KB

$ ruby disk_report.rb disktest --stale-days 30 --pattern "*.tmp" --clean --yes
...
deleted disktest/cache/old_cache.tmp (781.2 KB)

Reclaimed: 781.2 KB

This is real output captured while testing the script: the dry run and the real run agree exactly on what would be affected, and the file is confirmed gone from the directory afterward.

Scheduling it with cron

# Weekly report emailed to ops (redirect stdout, pipe to mail)
0 6 * * 1 /usr/bin/ruby /opt/scripts/disk_report.rb /var/log --top 15 | mail -s "Weekly disk report" ops@example.com

# Nightly cleanup of month-old rotated logs, actually deleting
30 3 * * * /usr/bin/ruby /opt/scripts/disk_report.rb /var/log --stale-days 30 --pattern "*.gz" --clean --yes >> /var/log/disk_cleanup.log 2>&1

Pair the second line with the cron job manager from a companion tutorial to add/update it safely with validation and automatic backups instead of hand-editing crontabs.

Troubleshooting

Symptom Likely cause / fix
Report undercounts total disk usage vs. df This tool sums file sizes, not allocated blocks; very large numbers of small files can consume more disk than their summed size due to filesystem block size. Compare against du, not df, for a fair comparison.
Scan is slow on huge trees Point it at a narrower subtree, or add a --max-depth option that stops Find.find early with Find.prune past a given depth.
Permission errors flood the output They shouldn’t — Errno::EACCES is caught and the file is silently skipped. If you’re seeing raw exceptions, confirm you’re on the version of the script above and haven’t removed the rescue clause.
--clean without --yes deletes nothing That’s intentional — it’s a dry run by default. Add --yes once you’ve reviewed the dry-run output.
Stale-file search matches nothing you expect Remember --pattern matches the filename only (via File.fnmatch), not the full path — use a pattern like "*.log", not "/var/log/*.log".

Extending this script

  • Add a --json output mode so results can feed a dashboard or monitoring system instead of a human-readable report
  • Track disk usage snapshots over time (e.g. append to a CSV each run) to graph growth trends per directory
  • Add a --min-size filter so the stale-file cleanup only targets files above a size threshold, ignoring tiny files not worth the risk
  • Support multiple root directories in one invocation for a fleet-wide-style report across several mount points
  • Send an alert (reusing the webhook pattern from a log-watching companion script) when total usage under a root crosses a threshold

Leave a Reply

Your email address will not be published. Required fields are marked *