Cleanup Script

This Ruby script makes preparing a distribution package easier by attempting to eliminate unmodified assets from your mod.

Technically you can use this for comparing, copying, and cleaning any two folders, but this is a OneShot modding guide.

Setup

Building the Catalogue

First, use the Build catalogue, where it will build some data with which is will compare your mod’s files to vanilla.

You must pass it the absolute path to a clean, vanilla OneShot directory. If you’re unsure if your copy is clean, follow the Uninstalling Mods instructions in For Players.

You only need to perform this action once, and if vanilla OneShot gets updated.

Copy & Clean

Then you can have it make a copy of your mod and clean it from unmodified assets with the Copy & clean option. It will create a new folder next to it named _dist_output, and output the results there.

require "digest"
require "json"
require "fileutils"
require "find"
require "pathname"

IGNORED_DIRS = [ ".", "..", ".git", ".vscode", ".idea", ".github", ".luminol" ]
IGNORED_FILES = [ ".gitignore" ]
CATALOGUE_PATH = "#{__dir__}/_hash_catalogue.json"
OUTPUT_DIR = "#{__dir__}/_dist_output"

def main()
    puts "Select operation:"
    puts "1 - Build catalogue"
    puts "2 - Copy & clean"
    op = STDIN.gets().chomp()
    
    if op == '1'
        build_catalogue()
    elsif op == '2'
        copy_and_clean()
    else
        raise "Invalid operation."
    end
    
    puts "Job's done!"
end


def build_catalogue()
    puts "Build a catalogue for comparison."
    puts "Requires an absolute path to a CLEAN, VANILLA OneShot."
    
    puts "Vanilla OneShot path:"
    oneshot_dir = STDIN.gets().chomp()
    raise "Invalid or non-existent directory." unless Dir.exist?(oneshot_dir)
    
    catalogue = {}
    
    Find.find(oneshot_dir) do |path|
        basename = File.basename(path)
        if FileTest.directory?(path)
            Find.prune if IGNORED_DIRS.include?(basename)
            next
        end
        
        next if IGNORED_FILES.include?(basename)
        
        relative = Pathname(path).relative_path_from(Pathname(oneshot_dir)).to_s
        puts "> Cataloguing: #{relative}"
        catalogue[relative] = hash_file(path)
    end
    
    File.write(CATALOGUE_PATH, catalogue.to_json)
end



def copy_and_clean()
    puts "Create a copy of your mod, then remove files identical to vanilla."
    puts "Requires building the catalogue first, and the absolute path to your mod's source folder."
    
    raise "Catalogue file not found. Build it first." unless File.exist?(CATALOGUE_PATH)
    
    puts "Mod's source directory:"
    mod_dir = STDIN.gets().chomp()
    raise "Invalid or non-existent directory." unless Dir.exist?(mod_dir)
    
    if Dir.exist?(OUTPUT_DIR) && !Dir.empty?(OUTPUT_DIR)
        puts "Output directory '#{OUTPUT_DIR}' is not empty."
        puts "Delete and proceed? [y/N]"
        proceed = STDIN.gets&.chomp&.downcase == "y"
        raise "Output directory not empty, process terminated." unless proceed
    end
    
    FileUtils.rm_rf(OUTPUT_DIR)
    
    Find.find(mod_dir) do |path|
        basename = File.basename(path)
        if FileTest.directory?(path)
            Find.prune if IGNORED_DIRS.include?(basename)
            next
        end
        
        next if IGNORED_FILES.include?(basename)
        
        relative = Pathname(path).relative_path_from(Pathname(mod_dir)).to_s
        puts "> Copying: #{relative}"
        
        dest = File.join(OUTPUT_DIR, relative)
        FileUtils.mkdir_p(File.dirname(dest))
        FileUtils.cp(path, dest)
    end
    
    catalogue = {}
    begin
        catalogue = JSON.parse(File.read(CATALOGUE_PATH))
    rescue JSON::ParserError => err
        raise "Failed parsing catalogue: #{err.message}"
    end

    nonexistent = []
    identical = []
    modified = []
    
    catalogue.each do |relative, hash|
        full = "#{OUTPUT_DIR}/#{relative}"
        puts "> Checking: #{relative}"
        if File.exist?(full)
            hash == hash_file(full) ? identical.push(full) : modified.push(full)
        else
            nonexistent.push(full)
        end
    end
    
    identical.each do |file|
        puts "> Deleting identical file: #{file}"
        File.delete(file)
    end
    
    Dir.glob( "**/", base: OUTPUT_DIR ).reverse_each { |d|
        full = "#{OUTPUT_DIR}/#{d}"
        if Dir.empty?(full)
            puts "> Deleting empty directory: #{full}"
            Dir.rmdir(full)
        end
    }
    
    puts
    puts "=========================================================="
    puts "# Identical files deleted : #{identical.length}"
    puts "# Modified files kept     : #{modified.length}"
    puts "# New files               : #{nonexistent.length}"
    puts
end



def hash_file(file)
    return Digest::SHA2.file(file).hexdigest
end


begin
    main()
rescue StandardError => err
    puts "===== ERROR ====="
    puts err.message
ensure
    puts "Press 'Enter' to exit."
    STDIN.gets()
end