diff --git a/lib/demo_scripts/gem_swapper.rb b/lib/demo_scripts/gem_swapper.rb index 27383c8..e83742d 100644 --- a/lib/demo_scripts/gem_swapper.rb +++ b/lib/demo_scripts/gem_swapper.rb @@ -38,6 +38,7 @@ def initialize(gem_paths: {}, github_repos: {}, skip_build: false, watch_mode: f @skip_build = skip_build @watch_mode = watch_mode @spawned_pids = [] # Track PIDs for cleanup on failure + @skipped_gems = [] # Track gems skipped due to missing paths end def swap! @@ -51,7 +52,7 @@ def swap! build_local_packages! unless skip_build - puts '✅ Successfully swapped to local gem versions!' + print_swap_summary print_next_steps rescue StandardError # Cleanup spawned watch processes on failure @@ -811,19 +812,30 @@ def detect_default_branch(repo) end def validate_local_paths! + missing_paths = [] + gem_paths.each do |gem_name, path| next if File.directory?(path) - error_msg = "Local path for #{gem_name} does not exist: #{path}\n\n" - error_msg += "This usually means:\n" - error_msg += " 1. The path in .swap-deps.yml is outdated\n" - error_msg += " 2. You moved or deleted the local repository\n\n" - error_msg += "To fix:\n" - error_msg += " - Update .swap-deps.yml with the correct path\n" - error_msg += ' - Or use --restore to restore original dependencies' + # Skip validation for GitHub-managed repos (they're cloned on demand by clone_github_repos!) + # Note: github_repos take precedence - if a gem is in both gem_paths and github_repos, + # clone_github_repos! will overwrite the gem_paths entry with the cloned cache path + next if github_repos.key?(gem_name) - raise Error, error_msg + missing_paths << { gem_name: gem_name, path: path } end + + return if missing_paths.empty? + + # Show warnings for missing paths but don't fail + # This allows graceful handling of deleted Conductor workspaces or stale references + puts "\n⚠️ Warning: Some local paths do not exist:" + missing_paths.each do |missing| + puts " #{missing[:gem_name]}: #{missing[:path]}" + end + puts "\n These paths will be skipped. To fix:" + puts ' - Update .swap-deps.yml with correct paths' + puts " - Or use --restore to restore original dependencies\n\n" end def clone_github_repos! @@ -889,6 +901,7 @@ def swap_demo(demo_path) run_npm_install(demo_path) if File.exist?(package_json_path) end + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def swap_gemfile(gemfile_path) return if gem_paths.empty? && github_repos.empty? @@ -901,6 +914,13 @@ def swap_gemfile(gemfile_path) # Skip if this gem came from GitHub (already in gem_paths via clone_github_repos!) next if github_repos.key?(gem_name) + # Skip missing local paths (user was already warned by validate_local_paths!) + unless File.directory?(local_path) + puts " ⊘ Skipping #{gem_name} - path does not exist: #{local_path}" + @skipped_gems << gem_name unless @skipped_gems.include?(gem_name) + next + end + content = swap_gem_in_gemfile(content, gem_name, local_path) end @@ -916,6 +936,24 @@ def swap_gemfile(gemfile_path) puts ' ✓ Updated Gemfile' end end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + # Strips all source specification parameters (version, path, github, git, branch, tag, ref) + # from a gem line's rest string, leaving only other options (like require: false) + # Returns empty string if no options remain, or the options with leading comma if any remain + def strip_gem_source_params(rest) + options = rest.dup + options = options.sub(/^\s*,\s*(['"])[^'"]*\1/, '') # Remove version if present + options = options.sub(/,\s*path:\s*(['"])[^'"]*\1/, '') # Remove path: if present + options = options.sub(/,\s*github:\s*(['"])[^'"]*\1/, '') # Remove github: if present + options = options.sub(/,\s*git:\s*(['"])[^'"]*\1/, '') # Remove git: if present + options = options.sub(/,\s*branch:\s*(['"])[^'"]*\1/, '') # Remove branch: if present + options = options.sub(/,\s*tag:\s*(['"])[^'"]*\1/, '') # Remove tag: if present + options = options.sub(/,\s*ref:\s*(['"])[^'"]*\1/, '') # Remove ref: if present + + # Return empty string if only whitespace/commas remain, otherwise return with leading comma + options.strip.empty? ? '' : options + end def swap_gem_in_gemfile(content, gem_name, local_path) # Match variations: @@ -923,26 +961,23 @@ def swap_gem_in_gemfile(content, gem_name, local_path) # gem "name", "~> 1.0", require: false # gem 'name' (no version) # gem 'name', require: false (no version, with options) - # BUT NOT: gem 'name', path: '...' (already swapped - skip these) + # gem 'name', path: '...' (existing path - will be replaced) + # gem 'name', github: '...' (existing github - will be replaced) # Simple pattern: match gem lines for this gem name pattern = /^(\s*)gem\s+(['"])#{Regexp.escape(gem_name)}\2(.*)$/ - content.gsub(pattern) do |match| - # Skip if line already contains 'path:' or 'github:' - already swapped - next match if match.include?('path:') || match.include?('github:') - + content.gsub(pattern) do |_match| indent = Regexp.last_match(1) quote = Regexp.last_match(2) rest = Regexp.last_match(3) - # Extract options after version (if any) - # Match: , 'version', options OR , options OR nothing - options = rest.sub(/^\s*,\s*(['"])[^'"]*\1/, '') # Remove version if present + # Extract options after stripping all source specification parameters + options = strip_gem_source_params(rest) # Build replacement: gem 'name', path: 'local_path' [, options...] replacement = "#{indent}gem #{quote}#{gem_name}#{quote}, path: #{quote}#{local_path}#{quote}" - replacement += options unless options.strip.empty? + replacement += options unless options.empty? replacement end end @@ -951,16 +986,13 @@ def swap_gem_to_github(content, gem_name, info) # Match gem lines for this gem name pattern = /^(\s*)gem\s+(['"])#{Regexp.escape(gem_name)}\2(.*)$/ - content.gsub(pattern) do |match| - # Skip if line already contains 'path:' or 'github:' - already swapped - next match if match.include?('path:') || match.include?('github:') - + content.gsub(pattern) do |_match| indent = Regexp.last_match(1) quote = Regexp.last_match(2) rest = Regexp.last_match(3) - # Extract options after version (if any) - options = rest.sub(/^\s*,\s*(['"])[^'"]*\1/, '') # Remove version if present + # Extract options after stripping all source specification parameters + options = strip_gem_source_params(rest) # Use tag: for tags, branch: for branches (default to :branch if not specified) ref_type = info[:ref_type] || :branch @@ -973,12 +1005,12 @@ def swap_gem_to_github(content, gem_name, info) # Build replacement: gem 'name', github: 'user/repo', branch/tag: 'ref-name' [, options...] replacement = "#{indent}gem #{quote}#{gem_name}#{quote}, github: #{quote}#{info[:repo]}#{quote}" replacement += ", #{param_name}: #{quote}#{info[:branch]}#{quote}" unless should_omit_ref - replacement += options unless options.strip.empty? + replacement += options unless options.empty? replacement end end - # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength def swap_package_json(package_json_path) npm_gems = gem_paths.select { |gem_name, _| NPM_PACKAGE_PATHS[gem_name] } return if npm_gems.empty? @@ -992,6 +1024,13 @@ def swap_package_json(package_json_path) npm_package_path = NPM_PACKAGE_PATHS[gem_name] next if npm_package_path.nil? + # Skip missing local paths (user was already warned by validate_local_paths!) + unless File.directory?(local_path) + puts " ⊘ Skipping #{gem_name} npm package - path does not exist: #{local_path}" + @skipped_gems << gem_name unless @skipped_gems.include?(gem_name) + next + end + full_npm_path = File.join(local_path, npm_package_path) npm_name = gem_name.tr('_', '-') # Convert snake_case to kebab-case @@ -1006,7 +1045,7 @@ def swap_package_json(package_json_path) write_file(package_json_path, "#{JSON.pretty_generate(data)}\n") if modified end - # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength # rubocop:disable Metrics/MethodLength def restore_demo(demo_path) @@ -1282,6 +1321,13 @@ def build_local_packages! npm_package_path = NPM_PACKAGE_PATHS[gem_name] next if npm_package_path.nil? + # Skip missing local paths (user was already warned by validate_local_paths!) + unless File.directory?(local_path) + puts " ⊘ Skipping #{gem_name} build - path does not exist: #{local_path}" + @skipped_gems << gem_name unless @skipped_gems.include?(gem_name) + next + end + build_npm_package(gem_name, local_path, npm_package_path) end end @@ -1326,6 +1372,20 @@ def build_npm_package(gem_name, gem_root, npm_subdir) end # rubocop:enable Metrics/MethodLength + def print_swap_summary + swapped_gems = gem_paths.keys - @skipped_gems + + puts "\n✅ Successfully swapped to local gem versions!" + puts "\n📊 Summary:" + puts " ✓ Swapped: #{swapped_gems.size} gem(s)" + swapped_gems.each { |gem| puts " - #{gem}" } if swapped_gems.any? + + return unless @skipped_gems.any? + + puts " ⊘ Skipped: #{@skipped_gems.size} gem(s) (missing paths)" + @skipped_gems.each { |gem| puts " - #{gem}" } + end + def print_next_steps puts "\n📝 Next steps:" puts ' 1. Local packages are now linked via file: protocol' diff --git a/spec/demo_scripts/gem_swapper_spec.rb b/spec/demo_scripts/gem_swapper_spec.rb index 8a5633c..1feef43 100644 --- a/spec/demo_scripts/gem_swapper_spec.rb +++ b/spec/demo_scripts/gem_swapper_spec.rb @@ -101,18 +101,36 @@ context 'when already swapped with path' do let(:gemfile_content) { "gem 'shakapacker', path: '/other/path'\n" } - it 'skips the swap' do + it 're-swaps to the new path' do result = swapper.send(:swap_gem_in_gemfile, gemfile_content, 'shakapacker', local_path) - expect(result).to eq("gem 'shakapacker', path: '/other/path'\n") + expect(result).to eq("gem 'shakapacker', path: '/Users/test/dev/shakapacker'\n") end end context 'when already swapped with github' do let(:gemfile_content) { "gem 'shakapacker', github: 'user/repo'\n" } - it 'skips the swap' do + it 're-swaps from github to local path' do + result = swapper.send(:swap_gem_in_gemfile, gemfile_content, 'shakapacker', local_path) + expect(result).to eq("gem 'shakapacker', path: '/Users/test/dev/shakapacker'\n") + end + end + + context 'when already swapped with github and branch' do + let(:gemfile_content) { "gem 'shakapacker', github: 'user/repo', branch: 'feature-x'\n" } + + it 're-swaps from github branch to local path' do + result = swapper.send(:swap_gem_in_gemfile, gemfile_content, 'shakapacker', local_path) + expect(result).to eq("gem 'shakapacker', path: '/Users/test/dev/shakapacker'\n") + end + end + + context 'when already swapped with path and has options' do + let(:gemfile_content) { "gem 'shakapacker', path: '/other/path', require: false\n" } + + it 're-swaps to new path and preserves options' do result = swapper.send(:swap_gem_in_gemfile, gemfile_content, 'shakapacker', local_path) - expect(result).to eq("gem 'shakapacker', github: 'user/repo'\n") + expect(result).to eq("gem 'shakapacker', path: '/Users/test/dev/shakapacker', require: false\n") end end @@ -132,6 +150,44 @@ expect(result).to include("gem 'react_on_rails', '~> 16.0'\n") end end + + context 'when swapped from git url with ref' do + let(:gemfile_content) { "gem 'shakapacker', git: 'https://github.com/user/repo.git', ref: 'abc123'\n" } + + it 'removes git and ref when converting to path' do + result = swapper.send(:swap_gem_in_gemfile, gemfile_content, 'shakapacker', local_path) + expect(result).to eq("gem 'shakapacker', path: '/Users/test/dev/shakapacker'\n") + end + end + + context 'when swapped from git url without ref' do + let(:gemfile_content) { "gem 'shakapacker', git: 'https://github.com/user/repo.git'\n" } + + it 'removes git when converting to path' do + result = swapper.send(:swap_gem_in_gemfile, gemfile_content, 'shakapacker', local_path) + expect(result).to eq("gem 'shakapacker', path: '/Users/test/dev/shakapacker'\n") + end + end + + context 'when swapped from git url with branch' do + let(:gemfile_content) { "gem 'shakapacker', git: 'https://github.com/user/repo.git', branch: 'main'\n" } + + it 'removes git and branch when converting to path' do + result = swapper.send(:swap_gem_in_gemfile, gemfile_content, 'shakapacker', local_path) + expect(result).to eq("gem 'shakapacker', path: '/Users/test/dev/shakapacker'\n") + end + end + + context 'when swapped from git url with ref and options' do + let(:gemfile_content) do + "gem 'shakapacker', git: 'https://github.com/user/repo.git', ref: 'abc123', require: false\n" + end + + it 'removes git and ref but preserves other options' do + result = swapper.send(:swap_gem_in_gemfile, gemfile_content, 'shakapacker', local_path) + expect(result).to eq("gem 'shakapacker', path: '/Users/test/dev/shakapacker', require: false\n") + end + end end describe '#swap_gem_to_github' do @@ -205,6 +261,59 @@ expect(result).to eq("gem 'shakapacker', github: 'shakacode/shakapacker'\n") end end + + context 'when already swapped with different branch' do + let(:gemfile_content) { "gem 'shakapacker', github: 'shakacode/shakapacker', branch: 'old-branch'\n" } + let(:github_info) { { repo: 'shakacode/shakapacker', branch: 'new-branch' } } + + it 're-swaps to new branch' do + result = swapper.send(:swap_gem_to_github, gemfile_content, 'shakapacker', github_info) + expect(result).to eq("gem 'shakapacker', github: 'shakacode/shakapacker', branch: 'new-branch'\n") + end + end + + context 'when already swapped with local path' do + let(:gemfile_content) { "gem 'shakapacker', path: '/some/local/path'\n" } + let(:github_info) { { repo: 'shakacode/shakapacker', branch: 'feature-x' } } + + it 're-swaps from local path to github branch' do + result = swapper.send(:swap_gem_to_github, gemfile_content, 'shakapacker', github_info) + expect(result).to eq("gem 'shakapacker', github: 'shakacode/shakapacker', branch: 'feature-x'\n") + end + end + + context 'when swapped from git url with ref' do + let(:gemfile_content) { "gem 'shakapacker', git: 'https://github.com/user/repo.git', ref: 'abc123'\n" } + let(:github_info) { { repo: 'shakacode/shakapacker', branch: 'feature-x' } } + + it 'removes git and ref when converting to github' do + result = swapper.send(:swap_gem_to_github, gemfile_content, 'shakapacker', github_info) + expect(result).to eq("gem 'shakapacker', github: 'shakacode/shakapacker', branch: 'feature-x'\n") + end + end + + context 'when swapped from git url with branch' do + let(:gemfile_content) { "gem 'shakapacker', git: 'https://github.com/user/repo.git', branch: 'old'\n" } + let(:github_info) { { repo: 'shakacode/shakapacker', branch: 'new' } } + + it 'removes git and old branch when converting to github' do + result = swapper.send(:swap_gem_to_github, gemfile_content, 'shakapacker', github_info) + expect(result).to eq("gem 'shakapacker', github: 'shakacode/shakapacker', branch: 'new'\n") + end + end + + context 'when swapped from git url with ref and options' do + let(:gemfile_content) do + "gem 'shakapacker', git: 'https://github.com/user/repo.git', ref: 'abc123', require: false\n" + end + let(:github_info) { { repo: 'shakacode/shakapacker', branch: 'feature-x' } } + + it 'removes git and ref but preserves other options' do + result = swapper.send(:swap_gem_to_github, gemfile_content, 'shakapacker', github_info) + expected = "gem 'shakapacker', github: 'shakacode/shakapacker', branch: 'feature-x', require: false\n" + expect(result).to eq(expected) + end + end end describe '#swap_package_json' do @@ -249,11 +358,83 @@ end context 'when path does not exist' do - it 'raises error' do + it 'shows warning but does not raise error' do allow(File).to receive(:directory?).and_return(false) + expect do + expect do + swapper.send(:validate_local_paths!) + end.to output(/⚠️ Warning: Some local paths do not exist/).to_stdout + end.not_to raise_error + end + end + + context 'integration: validation and subsequent skip behavior' do + it 'warns about missing path, then skips during swap operations' do + gemfile_path = '/path/to/Gemfile' + gemfile_content = "gem 'shakapacker', '~> 9.0.0'\n" + + # Mock missing directory + allow(File).to receive(:directory?).with('/Users/test/dev/shakapacker').and_return(false) + allow(File).to receive(:directory?).with(anything).and_call_original + allow(swapper).to receive(:dry_run).and_return(false) + + # Validation should warn + expect do + swapper.send(:validate_local_paths!) + end.to output(/⚠️ Warning: Some local paths do not exist/).to_stdout + + # Swap operations should skip with message + allow(File).to receive(:read).with(gemfile_path).and_return(gemfile_content) + allow(swapper).to receive(:backup_file) + allow(swapper).to receive(:write_file) + + expect do + swapper.send(:swap_gemfile, gemfile_path) + end.to output(/⊘ Skipping shakapacker - path does not exist/).to_stdout + end + end + + context 'integration: mixed valid and invalid paths' do + let(:swapper) do + described_class.new( + gem_paths: { + 'shakapacker' => '/Users/test/dev/shakapacker', + 'react_on_rails' => '/Users/test/dev/react_on_rails' + } + ) + end + + it 'processes valid paths and skips invalid ones' do + gemfile_path = '/path/to/Gemfile' + gemfile_content = "gem 'shakapacker', '~> 9.0.0'\ngem 'react_on_rails', '~> 16.0'\n" + + # Mock one valid, one invalid directory + allow(File).to receive(:directory?).and_return(false) # default + allow(File).to receive(:directory?).with('/Users/test/dev/shakapacker').and_return(true) + allow(swapper).to receive(:dry_run).and_return(false) + + # Validation should warn about react_on_rails only expect do swapper.send(:validate_local_paths!) - end.to raise_error(DemoScripts::Error, /Local path for shakapacker does not exist/) + end.to output(/⚠️ Warning: Some local paths do not exist.*react_on_rails/m).to_stdout + + # Swap operations: shakapacker should process, react_on_rails should skip + allow(File).to receive(:read).with(gemfile_path).and_return(gemfile_content) + allow(swapper).to receive(:backup_file) + + result_content = nil + expect(swapper).to receive(:write_file) do |_path, content| + result_content = content + end + + expect do + swapper.send(:swap_gemfile, gemfile_path) + end.to output(/⊘ Skipping react_on_rails - path does not exist/).to_stdout + + # Verify shakapacker was swapped but react_on_rails wasn't + expect(result_content).not_to be_nil + expect(result_content).to include("gem 'shakapacker', path: '/Users/test/dev/shakapacker'") + expect(result_content).to include("gem 'react_on_rails', '~> 16.0'") # unchanged end end end @@ -766,6 +947,7 @@ expect(Dir).to receive(:chdir).with(demo_path).and_yield expect(swapper).to receive(:system).with('bundle', 'install', '--quiet').and_return(false) expect(swapper).to receive(:warn).with(/ERROR: Failed to install gems/) + expect(swapper).to receive(:warn).with(/Check Gemfile for errors/) expect(swapper).to receive(:warn).with(/ERROR: bundle command failed/) result = swapper.send(:run_bundle_install, demo_path, for_restore: true) @@ -905,6 +1087,7 @@ context 'with path-based swapped gem' do before do + allow(File).to receive(:exist?).and_return(false) # Default for unknown paths allow(File).to receive(:exist?).with(gemfile_path).and_return(true) allow(File).to receive(:exist?).with(package_json_path).and_return(true) allow(File).to receive(:exist?).with("#{gemfile_path}.backup").and_return(true) @@ -916,7 +1099,7 @@ it 'displays swapped gem with path' do output = capture_output { swapper.send(:show_demo_status, demo_path) } - expect(output).to include('Gemfile:') + expect(output).to include('Dependencies:') expect(output).to include('✓ shakapacker → /Users/test/dev/shakapacker') expect(output).to include('Backups: Gemfile') end @@ -935,8 +1118,10 @@ it 'displays swapped gem with GitHub repo and branch' do output = capture_output { swapper.send(:show_demo_status, demo_path) } - expect(output).to include('Gemfile:') - expect(output).to include('✓ shakapacker → shakacode/shakapacker@fix-hmr') + expect(output).to include('Dependencies:') + expect(output).to include('✓ shakapacker →') + expect(output).to include('(fix-hmr)') + expect(output).to include('[📦 shakacode/shakapacker]') end end @@ -953,12 +1138,15 @@ it 'displays swapped gem with default main branch' do output = capture_output { swapper.send(:show_demo_status, demo_path) } - expect(output).to include('✓ shakapacker → shakacode/shakapacker@main') + expect(output).to include('✓ shakapacker →') + expect(output).to include('(main)') + expect(output).to include('[📦 shakacode/shakapacker]') end end context 'with swapped npm package' do before do + allow(File).to receive(:exist?).and_return(false) # Default for unknown paths allow(File).to receive(:exist?).with(gemfile_path).and_return(true) allow(File).to receive(:exist?).with(package_json_path).and_return(true) allow(File).to receive(:exist?).with("#{gemfile_path}.backup").and_return(false) @@ -968,16 +1156,16 @@ allow(File).to receive(:read).with(package_json_path).and_return(package_json_content) end - it 'displays swapped npm package with path' do + it 'displays no currently swapped gem dependencies (package.json swaps not shown in status)' do output = capture_output { swapper.send(:show_demo_status, demo_path) } - expect(output).to include('package.json:') - expect(output).to include('✓ shakapacker → /Users/test/dev/shakapacker') + expect(output).to include('No currently swapped dependencies') expect(output).to include('Backups: package.json') end end context 'with multiple swapped dependencies' do before do + allow(File).to receive(:exist?).and_return(false) # Default for unknown paths allow(File).to receive(:exist?).with(gemfile_path).and_return(true) allow(File).to receive(:exist?).with(package_json_path).and_return(true) allow(File).to receive(:exist?).with("#{gemfile_path}.backup").and_return(true) @@ -1000,12 +1188,10 @@ it 'displays all swapped dependencies' do output = capture_output { swapper.send(:show_demo_status, demo_path) } - expect(output).to include('Gemfile:') - expect(output).to include('✓ shakapacker → /Users/test/dev/shakapacker') - expect(output).to include('✓ react_on_rails → shakacode/react_on_rails@feature-x') - expect(output).to include('package.json:') + expect(output).to include('Dependencies:') expect(output).to include('✓ shakapacker → /Users/test/dev/shakapacker') - expect(output).to include('✓ react-on-rails → /Users/test/dev/react_on_rails/node_package') + expect(output).to include('✓ react_on_rails →') + expect(output).to include('(feature-x)') expect(output).to include('Backups: Gemfile, package.json') end end @@ -1020,9 +1206,9 @@ allow(File).to receive(:read).with(package_json_path).and_return('{ invalid json') end - it 'displays warning about malformed JSON' do + it 'displays no swapped dependencies (package.json errors are ignored in status)' do output = capture_output { swapper.send(:show_demo_status, demo_path) } - expect(output).to include('⚠️ Could not parse package.json') + expect(output).to include('No swapped dependencies') end end