Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions lib/demo_scripts/gem_swapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require 'pathname'
require 'fileutils'
require 'open3'
require 'find'

module DemoScripts
# Manages swapping dependencies between production and local/GitHub versions
Expand Down Expand Up @@ -130,6 +131,52 @@ def kill_watch_processes
end
# rubocop:enable Metrics/MethodLength

# CLI entry point: Display cache information including location, size, and cached repositories
def show_cache_info
unless File.directory?(CACHE_DIR)
puts 'ℹ️ Cache directory does not exist'
puts " Location: #{CACHE_DIR}"
return
end

# Get all repo directories once to avoid race conditions
repo_dirs = cache_repo_dirs

# Calculate cache size and count repos
repo_info = repo_dirs.map do |path|
{ path: path, basename: File.basename(path), size: directory_size(path) }
end

total_size = repo_info.sum { |info| info[:size] }

puts '📊 Cache information:'
puts " Location: #{CACHE_DIR}"
puts " Repositories: #{repo_info.count}"
puts " Total size: #{human_readable_size(total_size)}"

return unless repo_info.any?

puts "\n Cached repositories:"
repo_info.each do |info|
puts " - #{info[:basename]} (#{human_readable_size(info[:size])})"
end
end

# CLI entry point: Remove cached GitHub repositories
# @param gem_name [String, nil] Optional gem name to clean specific gem cache, or nil to clean all
def clean_cache(gem_name: nil)
unless File.directory?(CACHE_DIR)
puts 'ℹ️ Cache directory does not exist - nothing to clean'
return
end

if gem_name
clean_gem_cache(gem_name)
else
clean_all_cache
end
end

def load_config(config_file)
return unless File.exist?(config_file)

Expand All @@ -154,6 +201,126 @@ def load_config(config_file)

private

def cache_repo_dirs
return [] unless File.directory?(CACHE_DIR)

Dir.glob(File.join(CACHE_DIR, '*')).select do |path|
File.directory?(path) && File.basename(path) != 'watch_logs'
end
end

# Match gem name in cache directory pattern: {org}-{gem}-{branch}
# This ensures we match the repository component, not the org or branch
def matches_gem_cache_pattern?(basename, gem_name)
# Normalize gem name for both underscore and hyphen variants
normalized_gem = gem_name.tr('_', '-')

# Match the middle component after the first hyphen
# Pattern: ^{org}-{gem}-{branch}$
# This prevents false positives like matching "test" in "test-user-repo-branch"
basename.match?(/\A[^-]+-#{Regexp.escape(normalized_gem)}-/) ||
basename.match?(/\A[^-]+-#{Regexp.escape(gem_name)}-/)
end

def directory_size(path)
size = 0
Find.find(path) do |file_path|
# Skip symlinks to avoid circular references and incorrect sizes
if File.symlink?(file_path)
Find.prune
next
end

size += File.size(file_path) if File.file?(file_path)
end
size
rescue Errno::EACCES => e
warn " ⚠️ Warning: Permission denied accessing #{path}: #{e.message}" if verbose
0
rescue Errno::ENOENT => e
warn " ⚠️ Warning: Path not found #{path}: #{e.message}" if verbose
0
rescue StandardError => e
warn " ⚠️ Warning: Error calculating size for #{path}: #{e.message}" if verbose
0
end

def human_readable_size(bytes)
units = %w[B KB MB GB TB]
return "0 #{units[0]}" if bytes.zero?

exp = (Math.log(bytes) / Math.log(1024)).to_i
exp = [exp, units.length - 1].min
size = bytes.to_f / (1024**exp)
format('%<size>.2f %<unit>s', size: size, unit: units[exp])
end

# rubocop:disable Metrics/MethodLength
def clean_gem_cache(gem_name)
# Validate gem name to prevent path traversal
unless gem_name.match?(/\A[\w.-]+\z/)
raise Error,
"Invalid gem name: #{gem_name}. Only alphanumeric characters, hyphens, underscores, and dots allowed."
end

# Find all cached repos for this gem
# Expected format: {org}-{repo}-{branch} (e.g., shakacode-shakapacker-main)
matching_dirs = cache_repo_dirs.select do |path|
matches_gem_cache_pattern?(File.basename(path), gem_name)
end

if matching_dirs.empty?
puts "ℹ️ No cached repositories found for: #{gem_name}"
return
end

puts "🗑️ Cleaning cache for #{gem_name}..."
matching_dirs.each do |dir|
size = directory_size(dir)
basename = File.basename(dir)
if dry_run
puts " [DRY-RUN] Would remove #{basename} (#{human_readable_size(size)})"
else
FileUtils.rm_rf(dir)
puts " ✓ Removed #{basename} (#{human_readable_size(size)})"
end
end
end
# rubocop:enable Metrics/MethodLength

# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def clean_all_cache
# Get all repo directories (exclude watch_logs)
repo_dirs = cache_repo_dirs

if repo_dirs.empty?
puts 'ℹ️ Cache is empty - nothing to clean'
return
end

# Calculate sizes once to avoid redundant directory traversal
repo_info = repo_dirs.map do |dir|
{ path: dir, basename: File.basename(dir), size: directory_size(dir) }
end

total_size = repo_info.sum { |info| info[:size] }
puts "🗑️ Cleaning entire cache (#{repo_info.count} repositories, #{human_readable_size(total_size)})..."

if dry_run
puts ' [DRY-RUN] Would remove:'
repo_info.each do |info|
puts " - #{info[:basename]} (#{human_readable_size(info[:size])})"
end
else
repo_info.each do |info|
FileUtils.rm_rf(info[:path])
puts " ✓ Removed #{info[:basename]} (#{human_readable_size(info[:size])})"
end
puts "✅ Cleaned cache - freed #{human_readable_size(total_size)}"
end
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength

def load_watch_pids
return {} unless File.exist?(WATCH_PIDS_FILE)

Expand Down
50 changes: 46 additions & 4 deletions lib/demo_scripts/swap_deps_cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ class SwapDepsCLI
CONFIG_FILE = '.swap-deps.yml'

attr_reader :gem_paths, :github_repos, :dry_run, :verbose, :restore, :apply_config,
:skip_build, :watch_mode, :demo_filter, :demos_dir, :list_watch, :kill_watch
:skip_build, :watch_mode, :demo_filter, :demos_dir, :list_watch, :kill_watch,
:show_cache, :clean_cache, :clean_cache_gem

def initialize
@gem_paths = {}
Expand All @@ -26,17 +27,24 @@ def initialize
@auto_demos_dir = nil
@list_watch = false
@kill_watch = false
@show_cache = false
@clean_cache = false
@clean_cache_gem = nil
end

# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def run!
detect_context!
parse_options!

# Require bundler/setup only when actually running commands (not for --help)
require 'bundler/setup'

if @list_watch
if @show_cache
show_cache_info
elsif @clean_cache || @clean_cache_gem
clean_cache_handler
elsif @list_watch
list_watch_processes
elsif @kill_watch
kill_watch_processes
Expand All @@ -60,7 +68,7 @@ def run!
warn e.backtrace.join("\n") if verbose
exit 1
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity

private

Expand Down Expand Up @@ -175,6 +183,21 @@ def parse_options!
@kill_watch = true
end

opts.separator ''
opts.separator 'Cache management:'

opts.on('--show-cache', 'Show cache location, size, and cached repositories') do
@show_cache = true
end

opts.on('--clean-cache [GEM]', 'Remove cached repositories (all or specific gem, excludes watch_logs)') do |gem|
if gem
@clean_cache_gem = gem
else
@clean_cache = true
end
end

opts.separator ''
opts.separator 'General options:'

Expand Down Expand Up @@ -235,6 +258,15 @@ def parse_options!
puts ' # Stop all watch processes'
puts ' bin/swap-deps --kill-watch'
puts ''
puts ' # Show cache information'
puts ' bin/swap-deps --show-cache'
puts ''
puts ' # Clean all cached repositories'
puts ' bin/swap-deps --clean-cache'
puts ''
puts ' # Clean cache for specific gem'
puts ' bin/swap-deps --clean-cache shakapacker'
puts ''
puts 'Configuration file:'
puts " Create #{CONFIG_FILE} (see #{CONFIG_FILE}.example) with your dependency paths."
puts ' This file is git-ignored for local development.'
Expand Down Expand Up @@ -266,6 +298,16 @@ def kill_watch_processes
swapper.kill_watch_processes
end

def show_cache_info
swapper = create_swapper
swapper.show_cache_info
end

def clean_cache_handler
swapper = create_swapper
swapper.clean_cache(gem_name: @clean_cache_gem)
end

def apply_from_config
# Use root config if in demo directory, otherwise look for local config
config_file = @root_config_file || CONFIG_FILE
Expand Down
Loading