Skip to content

Instantly share code, notes, and snippets.

@alex-quiterio
Last active September 4, 2026 08:38
Show Gist options
  • Select an option

  • Save alex-quiterio/4e2399b838d184e30cab0b12357c035e to your computer and use it in GitHub Desktop.

Select an option

Save alex-quiterio/4e2399b838d184e30cab0b12357c035e to your computer and use it in GitHub Desktop.
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'fileutils'
require 'optparse'
require 'time'
RESET = "\e[0m"
DIM = "\e[2m"
BOLD = "\e[1m"
def tilde(path)
path.sub(/\A#{Regexp.escape(ENV['HOME'].to_s)}/, '~')
end
def human_size(bytes)
units = %w[B KB MB GB TB]
index = 0
size = bytes.to_f
while size >= 1024 && index < units.size - 1
size /= 1024
index += 1
end
format(index.zero? ? '%d %s' : '%.1f %s', size, units[index])
end
# A single table, one row per yyyy-mm bucket, redrawn in place as files are
# grouped instead of a stream of per-file log lines. Rows appear as their month
# is first seen, so the block only ever grows. On a non-tty it prints once, at
# the end.
class GroupTable
HEADERS = %w[Month Files Size Share].freeze
BAR_WIDTH = 22
EIGHTHS = %w[▏ ▎ ▍ ▌ ▋ ▊ ▉].freeze
ERRORS_SHOWN = 3
def initialize(source:, dry_run:, moving:, date_label:, verbose: false)
@source = source
@dry_run = dry_run
@moving = moving
@date_label = date_label
@verbose = verbose
@tty = $stdout.tty?
@drawn_lines = 0
@months = {} # 'yyyy-mm' => { files:, bytes: }
@skipped = Hash.new { |hash, key| hash[key] = [] } # pattern => [filename]
@errors = [] # [[filename, message]]
@status = nil
@month_width = [HEADERS[0].length, 7].max
@files_width = HEADERS[1].length
@size_width = 9
@table_width = column_widths.sum { |width| width + 2 } + 5
end
def record(month, bytes, destination)
bucket = (@months[month] ||= { files: 0, bytes: 0 })
bucket[:files] += 1
bucket[:bytes] += bytes
@status = "#{@dry_run ? 'would ' : ''}#{@moving ? 'move' : 'copy'} → #{destination}"
end
def skip(pattern, filename)
@skipped[pattern] << filename
end
def error(filename, message)
@errors << [filename, message]
end
def draw(final: false)
return unless @tty || final
rendered = render
if @tty
print "\e[?25l"
print "\e[#{@drawn_lines}A" if @drawn_lines.positive?
rendered.each { |line| print "\e[2K#{line}\n" }
print "\e[?25h" if final
else
rendered.each { |line| puts line }
end
@drawn_lines = rendered.size
end
def restore_cursor
print "\e[?25h" if @tty
end
private
def render
return [empty_line] + detail_lines if @months.empty?
lines = [title]
lines << border('┌', '┬', '┐')
lines << cells(*HEADERS, style: BOLD)
lines << border('├', '┼', '┤')
busiest = @months.values.map { |bucket| bucket[:files] }.max
@months.keys.sort.each do |month|
bucket = @months[month]
lines << cells(month, bucket[:files].to_s, human_size(bucket[:bytes]),
bar(bucket[:files], busiest))
end
lines << border('├', '┼', '┤')
lines << cells('Total', total_files.to_s, human_size(total_bytes), months_counted, style: BOLD)
lines << border('└', '┴', '┘')
lines << "#{DIM}#{clip(@status || 'scanning…')}#{RESET}"
lines << "#{DIM}#{clip(footer)}#{RESET}"
lines.concat(detail_lines)
end
def title
mode = @dry_run ? "#{DIM}(dry run)#{RESET} " : ''
action = @moving ? 'move' : 'copy'
"#{BOLD}Group#{RESET} #{mode}#{DIM}#{@source} · #{action} · by date #{@date_label}#{RESET}"
end
# A directory with nothing to group is one line, not an empty table: this
# script is run in a loop over every __triage-* folder.
def empty_line
details = [skipped_summary, errors_summary].compact
tail = details.empty? ? 'nothing to group' : "nothing to group — #{details.join(', ')}"
"#{BOLD}Group#{RESET} #{DIM}#{@source} · #{tail}#{RESET}"
end
def footer
details = [skipped_summary, errors_summary].compact
return 'clean run' if details.empty?
details.join(' · ')
end
def skipped_summary
total = @skipped.values.sum(&:size)
return nil if total.zero?
"#{total} skipped (#{@skipped.keys.sort.join(', ')})"
end
def errors_summary
return nil if @errors.empty?
"#{@errors.size} #{@errors.size == 1 ? 'error' : 'errors'}"
end
# Verbose spells out every failure and every skipped name; otherwise only the
# first few failures, so the block stays a glance rather than a log.
def detail_lines
lines = @errors.take(@verbose ? @errors.size : ERRORS_SHOWN).map do |filename, message|
detail('!', "#{filename}: #{message}")
end
hidden = @errors.size - lines.size
lines << detail('!', "+#{hidden} more") if hidden.positive?
return lines unless @verbose
@skipped.sort.each do |pattern, filenames|
lines << detail('~', "#{pattern}: #{filenames.sort.join(', ')}")
end
lines
end
def detail(marker, text)
"#{DIM} #{marker} #{clip(text, @table_width - 4)}#{RESET}"
end
# One eighth-block of resolution per column, scaled to the busiest month.
def bar(files, busiest)
filled = files.to_f / busiest * BAR_WIDTH
full = filled.floor
eighths = ((filled - full) * 8).round
if eighths == 8
full += 1
eighths = 0
end
blocks = ('█' * full) + (eighths.zero? ? '' : EIGHTHS[eighths - 1])
blocks + ('░' * (BAR_WIDTH - blocks.length))
end
def months_counted
"#{@months.size} #{@months.size == 1 ? 'month' : 'months'}"
end
def clip(text, width = @table_width)
text.length > width ? "#{text[0, width - 1]}…" : text
end
def column_widths
[@month_width, @files_width, @size_width, BAR_WIDTH]
end
def border(left, middle, right)
"#{DIM}#{left}#{column_widths.map { |width| '─' * (width + 2) }.join(middle)}#{right}#{RESET}"
end
def cells(month, files, size, share, style: nil)
columns = [
month.ljust(@month_width),
files.rjust(@files_width),
size.rjust(@size_width),
share.ljust(BAR_WIDTH)
]
pipe = "#{DIM}│#{RESET}"
row = columns.map { |column| "#{style}#{column}#{RESET if style}" }.join(" #{pipe} ")
"#{pipe} #{row} #{pipe}"
end
def total_files
@months.values.sum { |bucket| bucket[:files] }
end
def total_bytes
@months.values.sum { |bucket| bucket[:bytes] }
end
end
class FileGrouper
def initialize(source_dir, options = {})
@source_dir = File.expand_path(source_dir)
@dry_run = options[:dry_run] || false
@use_creation_date = options[:use_creation_date] || false
@verbose = options[:verbose] || false
@exclude_patterns = options[:exclude] || []
@move_files = options.fetch(:move, true)
end
def group_files
unless Dir.exist?(@source_dir)
warn "Error: Directory '#{@source_dir}' does not exist"
exit 1
end
table = GroupTable.new(source: tilde(@source_dir), dry_run: @dry_run, moving: @move_files,
date_label: @use_creation_date ? 'created' : 'modified',
verbose: @verbose)
begin
Dir.glob(File.join(@source_dir, '*')).sort.each { |file_path| process(file_path, table) }
table.draw(final: true)
ensure
table.restore_cursor
end
end
private
def process(file_path, table)
return if File.directory?(file_path)
filename = File.basename(file_path)
pattern = matching_exclude(filename)
return table.skip(pattern, filename) if pattern
year_month = file_date(file_path).strftime('%Y-%m')
target_dir = File.join(@source_dir, year_month)
target_path = File.join(target_dir, filename)
target_path = unique_name(target_path) if File.exist?(target_path) && target_path != file_path
bytes = File.size?(file_path).to_i
transfer(file_path, target_dir, target_path) unless @dry_run
table.record(year_month, bytes, "#{year_month}/#{File.basename(target_path)}")
table.draw
rescue StandardError => e
table.error(filename, e.message)
table.draw
end
def transfer(file_path, target_dir, target_path)
FileUtils.mkdir_p(target_dir) unless Dir.exist?(target_dir)
if @move_files
FileUtils.mv(file_path, target_path)
else
FileUtils.cp(file_path, target_path)
end
end
# birthtime is the real creation date on macOS; ctime (change time) is the
# best the filesystem offers where birthtime is unsupported.
def file_date(file_path)
return File.mtime(file_path) unless @use_creation_date
begin
File.birthtime(file_path)
rescue NotImplementedError
File.ctime(file_path)
end
end
def matching_exclude(filename)
@exclude_patterns.find do |pattern|
File.fnmatch?(pattern, filename, File::FNM_CASEFOLD)
end
end
def unique_name(original_path)
dir = File.dirname(original_path)
basename = File.basename(original_path, '.*')
extension = File.extname(original_path)
counter = 1
loop do
new_path = File.join(dir, "#{basename}_#{counter}#{extension}")
return new_path unless File.exist?(new_path)
counter += 1
end
end
end
# Command-line interface
if __FILE__ == $PROGRAM_NAME
options = {
dry_run: false,
use_creation_date: false,
verbose: false,
exclude: [],
move: true
}
parser = OptionParser.new do |opts|
opts.banner = "Usage: #{$PROGRAM_NAME} [options] DIRECTORY"
opts.separator ''
opts.separator 'Groups files in a directory by year-month (yyyy-mm) based on their dates.'
opts.separator ''
opts.separator 'Options:'
opts.on('-d', '--dry-run', 'Preview changes without modifying files') do
options[:dry_run] = true
options[:verbose] = true # Automatically enable verbose in dry-run mode
end
opts.on('-c', '--creation-date', 'Use creation date instead of modification date') do
options[:use_creation_date] = true
end
opts.on('-m', '--move', 'Move files (the default)') do
options[:move] = true
end
opts.on('-C', '--copy', 'Copy files instead of moving them') do
options[:move] = false
end
opts.on('-v', '--verbose', 'Spell out every error and every skipped file') do
options[:verbose] = true
end
opts.on('-e', '--exclude PATTERN', 'Exclude files matching pattern (can be used multiple times)') do |pattern|
options[:exclude] << pattern
end
opts.on('-h', '--help', 'Show this help message') do
puts opts
exit
end
end
begin
parser.parse!
if ARGV.empty?
warn 'Error: Please specify a directory'
warn parser
exit 1
end
FileGrouper.new(ARGV[0], options).group_files
rescue OptionParser::InvalidOption => e
warn "Error: #{e.message}"
warn parser
exit 1
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment