find /tmp -mtime +7 -delete has no dry-run with sizes, no per-directory rules, no idea which files are still open, and no audit trail. This Ruby tool replaces it with a small YAML policy, guard rails against catastrophic paths, and a report you can hand to whoever asks ‘what happened to my file?’
Full script + README on GitHub: ruby-devops-toolkit/stale-file-cleaner
Step through the build below:
The symptom: disk fills up, someone runs a hasty find ... -delete, and either nothing useful is freed or a file that a process still had open vanishes. Or worse: a typo in the path and the wrong directory is emptied.
The approach: describe what may be deleted in a YAML file (path, max age, glob, minimum size, keep-newest-N, protected globs). The script evaluates every candidate through a fixed decision chain, refuses dangerous roots outright, skips files held open by any process (via /proc/*/fd), and defaults to dry-run.
What you get: a table of DELETE/KEEP decisions with reasons, total reclaimable bytes, --apply to execute, --prune-empty-dirs to tidy up, and --json for auditing.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# stale_file_cleaner.rb -- policy-driven cleanup of stale files on Linux.
#
# The classic `find /tmp -mtime +7 -delete` one-liner works until it doesn't:
# it has no dry-run that shows sizes, no per-directory rules, no protection
# for files still held open by a process, and no audit trail. This script
# fixes all of that with a small YAML policy file:
#
# rules:
# - path: /var/tmp
# max_age_days: 14
# pattern: "**/*" # glob relative to path (default: everything)
# - path: /var/log/myapp
# max_age_days: 30
# pattern: "**/*.log.*" # only rotated logs
# min_size_kb: 0
# - path: /home/deploy/releases
# max_age_days: 45
# keep_newest: 5 # never drop below N newest matches
# protect: # never touch anything matching these globs
# - "**/.git/**"
# - "**/*.pid"
#
# Usage:
# ruby stale_file_cleaner.rb --policy cleanup.yml # dry-run (default)
# ruby stale_file_cleaner.rb --policy cleanup.yml --apply # really delete
# ruby stale_file_cleaner.rb --policy cleanup.yml --json # JSON report
# ruby stale_file_cleaner.rb --policy cleanup.yml --apply --prune-empty-dirs
#
# Safety rails: dry-run by default, never follows symlinks, refuses to run a
# rule whose path is / or a home directory root, skips files that are still
# open (checked via /proc/*/fd), and logs every deletion with size and age.
require 'yaml'
require 'json'
require 'optparse'
require 'time'
require 'pathname'
FORBIDDEN_ROOTS = ['/', '/home', '/root', '/etc', '/usr', '/bin', '/sbin', '/lib', '/boot', '/var', '/proc', '/sys', '/dev'].freeze
# Candidate file plus the facts we decided on.
Candidate = Struct.new(:path, :size, :mtime, :age_days, :rule_path, :action, :reason)
class OpenFileIndex
# Build a Set of every file path currently held open by any process, by
# resolving /proc/<pid>/fd/* symlinks. Cheap enough to do once per run.
def initialize
@open = {}
Dir.glob('/proc/[0-9]*/fd/*').each do |fd|
target = File.readlink(fd)
@open[target] = true if target.start_with?('/')
rescue SystemCallError
next # process exited or permission denied; both fine
end
end
def open?(path)
@open.key?(path)
end
end
class StaleFileCleaner
attr_reader :candidates, :errors
def initialize(policy, apply: false, prune_empty_dirs: false, now: Time.now, logger: $stderr)
@rules = Array(policy['rules'])
@protect = Array(policy['protect'])
@apply = apply
@prune_empty_dirs = prune_empty_dirs
@now = now
@log = logger
@candidates = []
@errors = []
@open_index = nil
end
def run
@rules.each { |rule| evaluate_rule(rule) }
execute if @apply
self
end
# ---- evaluation -----------------------------------------------------
def evaluate_rule(rule)
root = File.expand_path(rule.fetch('path'))
if FORBIDDEN_ROOTS.include?(root) || root =~ %r{\A/home/[^/]+\z}
@errors << "refusing to clean #{root}: too dangerous as a rule root"
return
end
unless File.directory?(root)
@errors << "skipping #{root}: not a directory"
return
end
max_age = Float(rule.fetch('max_age_days'))
min_size = Integer(rule.fetch('min_size_kb', 0)) * 1024
keep_newest = Integer(rule.fetch('keep_newest', 0))
pattern = rule.fetch('pattern', '**/*')
# File::FNM_DOTMATCH so dotfiles count; we filter dirs/symlinks ourselves.
matches = Dir.glob(File.join(root, pattern), File::FNM_DOTMATCH)
.reject { |p| File.symlink?(p) || !File.file?(p) }
.reject { |p| protected?(p) }
.map { |p| [p, File.stat(p)] }
.sort_by { |_, st| -st.mtime.to_f } # newest first
matches.each_with_index do |(path, st), idx|
age = (@now - st.mtime) / 86_400.0
c = Candidate.new(path, st.size, st.mtime, age.round(1), root, :keep, nil)
if idx < keep_newest
c.reason = "within keep_newest=#{keep_newest}"
elsif age < max_age
c.reason = "younger than #{max_age.to_i}d"
elsif st.size < min_size
c.reason = "smaller than #{min_size / 1024}KB"
elsif open_index.open?(path)
c.reason = 'still open by a process'
else
c.action = :delete
c.reason = "#{age.round}d old > #{max_age.to_i}d"
end
@candidates << c
end
end
def protected?(path)
@protect.any? { |glob| File.fnmatch?(glob, path, File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB) }
end
def open_index
@open_index ||= OpenFileIndex.new
end
# ---- execution ------------------------------------------------------
def execute
deletable.each do |c|
File.delete(c.path)
c.action = :deleted
@log.puts "deleted #{c.path} (#{human(c.size)}, #{c.age_days}d)"
rescue SystemCallError => e
c.action = :failed
c.reason = e.message
@errors << "#{c.path}: #{e.message}"
end
prune_dirs if @prune_empty_dirs
end
# Remove now-empty directories under each rule root, deepest first, but
# never the rule root itself.
def prune_dirs
@rules.each do |rule|
root = File.expand_path(rule['path'])
next unless File.directory?(root)
Dir.glob(File.join(root, '**/'), File::FNM_DOTMATCH).map { |d| d.chomp('/') }
.reject { |d| d == root || d.end_with?('/.', '/..') }
.sort_by { |d| -d.count('/') }
.each do |d|
next unless (Dir.children(d) rescue [nil]).empty?
Dir.rmdir(d)
@log.puts "pruned empty dir #{d}"
rescue SystemCallError => e
@errors << "#{d}: #{e.message}"
end
end
end
# ---- reporting ------------------------------------------------------
def deletable
@candidates.select { |c| c.action == :delete }
end
def summary
done = @candidates.select { |c| c.action == :deleted }
{
mode: @apply ? 'apply' : 'dry-run',
scanned: @candidates.size,
to_delete: deletable.size + done.size,
bytes_reclaimable: (deletable + done).sum(&:size),
deleted: done.size,
bytes_freed: done.sum(&:size),
failed: @candidates.count { |c| c.action == :failed },
errors: @errors
}
end
def to_json(*_args)
JSON.pretty_generate(summary.merge(files: @candidates.map(&:to_h)))
end
def to_text
s = summary
lines = ["stale_file_cleaner mode=#{s[:mode]} scanned=#{s[:scanned]}"]
lines << ('-' * 72)
@candidates.select { |c| c.action != :keep }.sort_by(&:path).each do |c|
lines << format('%-8s %9s %6.1fd %s', c.action.to_s.upcase, human(c.size), c.age_days, c.path)
end
lines << ('-' * 72)
lines << "would reclaim #{human(s[:bytes_reclaimable])} across #{s[:to_delete]} file(s)" unless @apply
lines << "freed #{human(s[:bytes_freed])} across #{s[:deleted]} file(s), #{s[:failed]} failed" if @apply
s[:errors].each { |e| lines << "ERROR #{e}" }
lines.join("\n")
end
def human(bytes)
units = %w[B KB MB GB TB]
i = 0
b = bytes.to_f
while b >= 1024 && i < units.size - 1
b /= 1024
i += 1
end
i.zero? ? "#{bytes}B" : format('%.1f%s', b, units[i])
end
end
if __FILE__ == $PROGRAM_NAME
opts = { policy: nil, apply: false, json: false, prune: false }
OptionParser.new do |o|
o.banner = 'Usage: stale_file_cleaner.rb --policy FILE [--apply] [--json] [--prune-empty-dirs]'
o.on('--policy FILE', 'YAML policy file (required)') { |f| opts[:policy] = f }
o.on('--apply', 'Actually delete (default is dry-run)') { opts[:apply] = true }
o.on('--json', 'JSON report on stdout') { opts[:json] = true }
o.on('--prune-empty-dirs', 'Remove directories left empty after deletion') { opts[:prune] = true }
end.parse!
abort 'error: --policy FILE is required' unless opts[:policy]
policy = YAML.safe_load(File.read(opts[:policy]))
cleaner = StaleFileCleaner.new(policy, apply: opts[:apply], prune_empty_dirs: opts[:prune]).run
puts(opts[:json] ? cleaner.to_json : cleaner.to_text)
exit(cleaner.errors.empty? ? 0 : 1)
end
Guard rails first. FORBIDDEN_ROOTS plus a regex for /home/<user> mean a policy line like path: / is rejected with an error, not executed. Symlinks are never followed and only regular files are candidates.
The decision chain in evaluate_rule is deliberately linear: keep-newest wins, then age, then size, then open-file check, and only then DELETE. Each KEEP records a human-readable reason so the JSON report explains itself.
OpenFileIndex resolves every /proc/<pid>/fd/* symlink once, lazily, into a hash. Deleting an open file on Linux does not free space until the descriptor closes, so skipping those files is both safer and more honest about what you will reclaim.
$ ruby stale_file_cleaner.rb --policy cleanup.yml stale_file_cleaner mode=dry-run scanned=19 ------------------------------------------------------------------------ DELETE 300.0KB 30.0d /tmp/lab/logs/app.log.3 DELETE 300.0KB 40.0d /tmp/lab/logs/app.log.4 DELETE 300.0KB 50.0d /tmp/lab/logs/app.log.5 DELETE 300.0KB 60.0d /tmp/lab/logs/app.log.6 DELETE 300.0KB 70.0d /tmp/lab/logs/app.log.7 DELETE 700.0KB 36.0d /tmp/lab/releases/r4/build.tar DELETE 700.0KB 45.0d /tmp/lab/releases/r5/build.tar DELETE 700.0KB 54.0d /tmp/lab/releases/r6/build.tar DELETE 700.0KB 63.0d /tmp/lab/releases/r7/build.tar DELETE 700.0KB 72.0d /tmp/lab/releases/r8/build.tar DELETE 2.0MB 20.0d /tmp/lab/tmp/old-upload.bin ------------------------------------------------------------------------ would reclaim 6.9MB across 11 file(s) ERROR refusing to clean /home: too dangerous as a rule root exit=1 $ ruby stale_file_cleaner.rb --policy cleanup.yml --apply --prune-empty-dirs deleted /tmp/lab/tmp/old-upload.bin (2.0MB, 20.0d) deleted /tmp/lab/logs/app.log.3 (300.0KB, 30.0d) deleted /tmp/lab/logs/app.log.4 (300.0KB, 40.0d) deleted /tmp/lab/logs/app.log.5 (300.0KB, 50.0d) deleted /tmp/lab/logs/app.log.6 (300.0KB, 60.0d) deleted /tmp/lab/logs/app.log.7 (300.0KB, 70.0d) deleted /tmp/lab/releases/r4/build.tar (700.0KB, 36.0d) deleted /tmp/lab/releases/r5/build.tar (700.0KB, 45.0d) deleted /tmp/lab/releases/r6/build.tar (700.0KB, 54.0d) deleted /tmp/lab/releases/r7/build.tar (700.0KB, 63.0d) deleted /tmp/lab/releases/r8/build.tar (700.0KB, 72.0d) pruned empty dir /tmp/lab/releases/r4 pruned empty dir /tmp/lab/releases/r5 pruned empty dir /tmp/lab/releases/r6 pruned empty dir /tmp/lab/releases/r7 pruned empty dir /tmp/lab/releases/r8 stale_file_cleaner mode=apply scanned=19 ------------------------------------------------------------------------ DELETED 300.0KB 30.0d /tmp/lab/logs/app.log.3 DELETED 300.0KB 40.0d /tmp/lab/logs/app.log.4 DELETED 300.0KB 50.0d /tmp/lab/logs/app.log.5 DELETED 300.0KB 60.0d /tmp/lab/logs/app.log.6 DELETED 300.0KB 70.0d /tmp/lab/logs/app.log.7 DELETED 700.0KB 36.0d /tmp/lab/releases/r4/build.tar DELETED 700.0KB 45.0d /tmp/lab/releases/r5/build.tar DELETED 700.0KB 54.0d /tmp/lab/releases/r6/build.tar DELETED 700.0KB 63.0d /tmp/lab/releases/r7/build.tar DELETED 700.0KB 72.0d /tmp/lab/releases/r8/build.tar DELETED 2.0MB 20.0d /tmp/lab/tmp/old-upload.bin ------------------------------------------------------------------------ freed 6.9MB across 11 file(s), 0 failed ERROR refusing to clean /home: too dangerous as a rule root exit=1
The problem this solves
Cleaning stale files is one of those jobs every team automates badly. The usual cron line is find /var/tmp -type f -mtime +14 -delete. It is fine until you need a second rule for rotated logs, a third that keeps the last five release tarballs regardless of age, and a way to prove to a developer that yes, their upload was deleted on Tuesday because it was 20 days old and 2 MB.
There is also a subtle Linux gotcha: deleting a file that some process still has open does not free the space. The directory entry disappears, the inode stays until the last descriptor closes, and df does not move. Engineers then delete more, still see no change, and start restarting services. A cleaner that consults /proc/*/fd before deleting avoids the confusion entirely.
This tutorial builds stale_file_cleaner.rb: a single-file Ruby tool driven by a YAML policy. It reports before it acts, refuses to operate on catastrophic roots, protects globs like **/.git/**, and can optionally remove directories left empty. It uses nothing outside Ruby’s standard library.
Prerequisites
- Ruby 2.7+ (tested on 3.0.2). Stdlib only:
yaml,json,optparse,pathname. - Linux. The open-file check reads
/proc/*/fd; on other platforms it simply finds nothing open and the rest still works. - A policy file. Copy
cleanup.ymlfrom the repo and adjust paths. Run as a user that can read the targets (root for/var/tmp, typically).
The complete script
Reference copy of the whole script (the widget above has the same code with a copy button).
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# stale_file_cleaner.rb -- policy-driven cleanup of stale files on Linux.
#
# The classic `find /tmp -mtime +7 -delete` one-liner works until it doesn't:
# it has no dry-run that shows sizes, no per-directory rules, no protection
# for files still held open by a process, and no audit trail. This script
# fixes all of that with a small YAML policy file:
#
# rules:
# - path: /var/tmp
# max_age_days: 14
# pattern: "**/*" # glob relative to path (default: everything)
# - path: /var/log/myapp
# max_age_days: 30
# pattern: "**/*.log.*" # only rotated logs
# min_size_kb: 0
# - path: /home/deploy/releases
# max_age_days: 45
# keep_newest: 5 # never drop below N newest matches
# protect: # never touch anything matching these globs
# - "**/.git/**"
# - "**/*.pid"
#
# Usage:
# ruby stale_file_cleaner.rb --policy cleanup.yml # dry-run (default)
# ruby stale_file_cleaner.rb --policy cleanup.yml --apply # really delete
# ruby stale_file_cleaner.rb --policy cleanup.yml --json # JSON report
# ruby stale_file_cleaner.rb --policy cleanup.yml --apply --prune-empty-dirs
#
# Safety rails: dry-run by default, never follows symlinks, refuses to run a
# rule whose path is / or a home directory root, skips files that are still
# open (checked via /proc/*/fd), and logs every deletion with size and age.
require 'yaml'
require 'json'
require 'optparse'
require 'time'
require 'pathname'
FORBIDDEN_ROOTS = ['/', '/home', '/root', '/etc', '/usr', '/bin', '/sbin', '/lib', '/boot', '/var', '/proc', '/sys', '/dev'].freeze
# Candidate file plus the facts we decided on.
Candidate = Struct.new(:path, :size, :mtime, :age_days, :rule_path, :action, :reason)
class OpenFileIndex
# Build a Set of every file path currently held open by any process, by
# resolving /proc/<pid>/fd/* symlinks. Cheap enough to do once per run.
def initialize
@open = {}
Dir.glob('/proc/[0-9]*/fd/*').each do |fd|
target = File.readlink(fd)
@open[target] = true if target.start_with?('/')
rescue SystemCallError
next # process exited or permission denied; both fine
end
end
def open?(path)
@open.key?(path)
end
end
class StaleFileCleaner
attr_reader :candidates, :errors
def initialize(policy, apply: false, prune_empty_dirs: false, now: Time.now, logger: $stderr)
@rules = Array(policy['rules'])
@protect = Array(policy['protect'])
@apply = apply
@prune_empty_dirs = prune_empty_dirs
@now = now
@log = logger
@candidates = []
@errors = []
@open_index = nil
end
def run
@rules.each { |rule| evaluate_rule(rule) }
execute if @apply
self
end
# ---- evaluation -----------------------------------------------------
def evaluate_rule(rule)
root = File.expand_path(rule.fetch('path'))
if FORBIDDEN_ROOTS.include?(root) || root =~ %r{\A/home/[^/]+\z}
@errors << "refusing to clean #{root}: too dangerous as a rule root"
return
end
unless File.directory?(root)
@errors << "skipping #{root}: not a directory"
return
end
max_age = Float(rule.fetch('max_age_days'))
min_size = Integer(rule.fetch('min_size_kb', 0)) * 1024
keep_newest = Integer(rule.fetch('keep_newest', 0))
pattern = rule.fetch('pattern', '**/*')
# File::FNM_DOTMATCH so dotfiles count; we filter dirs/symlinks ourselves.
matches = Dir.glob(File.join(root, pattern), File::FNM_DOTMATCH)
.reject { |p| File.symlink?(p) || !File.file?(p) }
.reject { |p| protected?(p) }
.map { |p| [p, File.stat(p)] }
.sort_by { |_, st| -st.mtime.to_f } # newest first
matches.each_with_index do |(path, st), idx|
age = (@now - st.mtime) / 86_400.0
c = Candidate.new(path, st.size, st.mtime, age.round(1), root, :keep, nil)
if idx < keep_newest
c.reason = "within keep_newest=#{keep_newest}"
elsif age < max_age
c.reason = "younger than #{max_age.to_i}d"
elsif st.size < min_size
c.reason = "smaller than #{min_size / 1024}KB"
elsif open_index.open?(path)
c.reason = 'still open by a process'
else
c.action = :delete
c.reason = "#{age.round}d old > #{max_age.to_i}d"
end
@candidates << c
end
end
def protected?(path)
@protect.any? { |glob| File.fnmatch?(glob, path, File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB) }
end
def open_index
@open_index ||= OpenFileIndex.new
end
# ---- execution ------------------------------------------------------
def execute
deletable.each do |c|
File.delete(c.path)
c.action = :deleted
@log.puts "deleted #{c.path} (#{human(c.size)}, #{c.age_days}d)"
rescue SystemCallError => e
c.action = :failed
c.reason = e.message
@errors << "#{c.path}: #{e.message}"
end
prune_dirs if @prune_empty_dirs
end
# Remove now-empty directories under each rule root, deepest first, but
# never the rule root itself.
def prune_dirs
@rules.each do |rule|
root = File.expand_path(rule['path'])
next unless File.directory?(root)
Dir.glob(File.join(root, '**/'), File::FNM_DOTMATCH).map { |d| d.chomp('/') }
.reject { |d| d == root || d.end_with?('/.', '/..') }
.sort_by { |d| -d.count('/') }
.each do |d|
next unless (Dir.children(d) rescue [nil]).empty?
Dir.rmdir(d)
@log.puts "pruned empty dir #{d}"
rescue SystemCallError => e
@errors << "#{d}: #{e.message}"
end
end
end
# ---- reporting ------------------------------------------------------
def deletable
@candidates.select { |c| c.action == :delete }
end
def summary
done = @candidates.select { |c| c.action == :deleted }
{
mode: @apply ? 'apply' : 'dry-run',
scanned: @candidates.size,
to_delete: deletable.size + done.size,
bytes_reclaimable: (deletable + done).sum(&:size),
deleted: done.size,
bytes_freed: done.sum(&:size),
failed: @candidates.count { |c| c.action == :failed },
errors: @errors
}
end
def to_json(*_args)
JSON.pretty_generate(summary.merge(files: @candidates.map(&:to_h)))
end
def to_text
s = summary
lines = ["stale_file_cleaner mode=#{s[:mode]} scanned=#{s[:scanned]}"]
lines << ('-' * 72)
@candidates.select { |c| c.action != :keep }.sort_by(&:path).each do |c|
lines << format('%-8s %9s %6.1fd %s', c.action.to_s.upcase, human(c.size), c.age_days, c.path)
end
lines << ('-' * 72)
lines << "would reclaim #{human(s[:bytes_reclaimable])} across #{s[:to_delete]} file(s)" unless @apply
lines << "freed #{human(s[:bytes_freed])} across #{s[:deleted]} file(s), #{s[:failed]} failed" if @apply
s[:errors].each { |e| lines << "ERROR #{e}" }
lines.join("\n")
end
def human(bytes)
units = %w[B KB MB GB TB]
i = 0
b = bytes.to_f
while b >= 1024 && i < units.size - 1
b /= 1024
i += 1
end
i.zero? ? "#{bytes}B" : format('%.1f%s', b, units[i])
end
end
if __FILE__ == $PROGRAM_NAME
opts = { policy: nil, apply: false, json: false, prune: false }
OptionParser.new do |o|
o.banner = 'Usage: stale_file_cleaner.rb --policy FILE [--apply] [--json] [--prune-empty-dirs]'
o.on('--policy FILE', 'YAML policy file (required)') { |f| opts[:policy] = f }
o.on('--apply', 'Actually delete (default is dry-run)') { opts[:apply] = true }
o.on('--json', 'JSON report on stdout') { opts[:json] = true }
o.on('--prune-empty-dirs', 'Remove directories left empty after deletion') { opts[:prune] = true }
end.parse!
abort 'error: --policy FILE is required' unless opts[:policy]
policy = YAML.safe_load(File.read(opts[:policy]))
cleaner = StaleFileCleaner.new(policy, apply: opts[:apply], prune_empty_dirs: opts[:prune]).run
puts(opts[:json] ? cleaner.to_json : cleaner.to_text)
exit(cleaner.errors.empty? ? 0 : 1)
end
How it works, step by step
The policy file
rules is a list; each rule has a path, a max_age_days, and optional pattern (a glob relative to the path, default **/*), min_size_kb, and keep_newest. protect is a list of globs that are never touched no matter which rule matched them. The file is loaded with YAML.safe_load, so no arbitrary object instantiation.
Guard rails in evaluate_rule
Before anything is globbed, the rule’s path is expanded and checked against FORBIDDEN_ROOTS and the /home/<user> regex. A rule that fails this check is turned into an error string and skipped, and the script exits non-zero so a cron mail tells you your policy is wrong. In the example run the deliberately bad path: /home rule demonstrates this.
Collecting candidates
Dir.glob with File::FNM_DOTMATCH includes dotfiles. The result is filtered to regular files that are not symlinks (so a symlink to /etc/passwd inside /tmp can never cause harm), then File.stat gives size and mtime. Sorting newest-first is what makes keep_newest a simple index comparison.
The decision chain
Each file becomes a Candidate struct with action defaulting to :keep. The chain is: within keep_newest? younger than max_age? smaller than min_size? still open? Only a file that clears all four becomes :delete. The reason string is filled in every branch, which is what makes the JSON report useful as an audit log.
The open-file index
OpenFileIndex globs /proc/[0-9]*/fd/* and readlinks each entry. Processes exit mid-scan and some are unreadable without root; both raise SystemCallError, which is rescued per entry. The index is built lazily (@open_index ||=), so a dry-run on a policy with no old files never pays the cost.
Executing and pruning
With --apply, execute deletes each candidate, logs it to stderr with size and age, and records failures without aborting the run. prune_dirs then walks directories deepest-first, removing only those that are empty and never the rule root itself. Empty-check uses Dir.children so . and .. do not count.
Example output
The lab tree has files aged 0-90 days, a 4 MB file held open by a sleep process, a .git directory, a .pid file, eight release tarballs and a deliberately dangerous /home rule. First a dry-run, then --apply --prune-empty-dirs.
When it does not behave
- ‘refusing to clean /var: too dangerous’. Intentional. Point the rule at a subdirectory such as
/var/tmpor/var/cache/myapp. EditFORBIDDEN_ROOTSonly if you really know what you are doing. - Old files show KEEP with ‘still open by a process’. Correct behaviour — something has the file open and deleting it would not free space. Find it with
lsof /path/to/fileorfuser. - Dotfiles not matched. Ensure your
patterndoes not exclude them; the script passesFNM_DOTMATCH, but a pattern like*.logonly matches that suffix. - Freed space does not appear in
df. Some other process still holds a deleted file (one the index could not see because you ran without root). Re-run as root or checklsof +L1. - Permission denied on delete. The report lists it under
failedand the exit code is 1; the rest of the run continues.
Where to take it next
- Move deletions to a quarantine directory (
FileUtils.mv) with a second rule that purges the quarantine after 7 days — instant undo for a week. - Add
min_free_percentto a rule so it only runs when the filesystem is actually under pressure (dfviaSys::Filesystemor parsing/proc/mounts+statvfs). - Emit a Prometheus textfile metric with bytes reclaimed per rule so you can graph how fast each directory grows.
- Support
atimeas an alternative tomtimefor caches where ‘last used’ matters more than ‘last written’. - Wrap it in a systemd timer with
ProtectSystem=strictandReadWritePaths=limited to the rule roots for an extra kernel-enforced guard rail.