Skip to content

Commit 0e76ec6

Browse files
justin808claude
andauthored
Fix swap-deps to handle missing reference directories gracefully (#64)
* Fix swap-deps to handle missing reference directories gracefully Previously, swap-deps would fail with an error if a local path reference in .swap-deps.yml or specified on the command line didn't exist. This was problematic in Conductor workflows where workspace directories and branches are frequently deleted after merging. Changes: - Modified validate_local_paths! to show warnings instead of raising errors for missing directories - Updated swap_gemfile, swap_package_json, and build_local_packages! to skip missing paths with informative messages - GitHub-managed repos are excluded from validation (they're cloned on demand) - Updated tests to reflect new warning behavior instead of error raising - Fixed test stubs to allow additional File.exist? calls for git branch detection This allows the tool to gracefully handle stale references and continue processing other valid dependencies. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Allow re-swapping between different paths and branches Previously, swap-deps would skip gems that were already swapped (had path: or github: in the Gemfile). This prevented updating to a different local path or switching between branches after a branch was deleted. Changes: - Modified swap_gem_in_gemfile to replace existing path: or github: references instead of skipping them - Modified swap_gem_to_github to replace existing path:, github:, branch:, and tag: references - Both methods now preserve other gem options (like require: false) when re-swapping - Added comprehensive tests for re-swapping scenarios: * Path to different path * GitHub branch to different branch * Path to GitHub branch * GitHub branch to path * Preserving options during re-swap This enables workflows where developers switch between different Conductor workspace directories or GitHub branches, even when the previous reference no longer exists. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Address code review feedback Changes: - Added clarifying comments about github_repos precedence in validate_local_paths! GitHub repos take precedence and overwrite gem_paths entries after cloning - Standardized skip message format across all methods: * swap_gemfile: "⊘ Skipping {gem} - path does not exist: {path}" * swap_package_json: "⊘ Skipping {gem} npm package - path does not exist: {path}" * build_local_packages!: "⊘ Skipping {gem} build - path does not exist: {path}" - Added integration test verifying validate_local_paths! warns and subsequent swap operations skip with appropriate messages - Updated comments to clarify that users are warned BEFORE skip messages appear All 91 tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Fix critical issue: Add git: and ref: to regex stripping Problem: When swapping from gems using git: urls (e.g., gem 'name', git: 'url', ref: 'sha'), the git: and ref: parameters were not being removed, resulting in invalid Gemfile entries like: gem 'name', path: '/path', git: 'url', ref: 'sha' This would cause Bundler parsing errors. Changes: - Added git: and ref: removal to swap_gem_in_gemfile regex substitutions - Added git: and ref: removal to swap_gem_to_github regex substitutions - Updated comments to reflect all source specification parameters are removed - Added 7 comprehensive test cases: * Swapping from git: url with ref: to path * Swapping from git: url without ref: to path * Swapping from git: url with branch: to path * Swapping from git: url with ref: and options to path (preserving options) * Swapping from git: url with ref: to github * Swapping from git: url with branch: to github * Swapping from git: url with ref: and options to github (preserving options) All 98 tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Refactor and improve code quality based on review feedback 1. Extract Duplicate Regex Stripping (DRY Principle) - Created strip_gem_source_params helper method to eliminate code duplication - Removes: version, path, github, git, branch, tag, ref parameters - Returns empty string if only whitespace remains, or options with leading comma - Fixes edge case: trailing commas from empty options after stripping 2. Add Swap Summary Output - Tracks skipped gems in @skipped_gems array - New print_swap_summary method shows: * Number of successfully swapped gems (with names) * Number of skipped gems due to missing paths (with names) - Makes silent skip behavior more visible to users 3. Comprehensive Integration Test - Added test for mixed valid/invalid paths scenario - Verifies: * Validation warns only about invalid paths * Valid paths are processed correctly * Invalid paths are skipped with appropriate messages * Gemfile content reflects only valid swaps All 99 tests pass. Benefits: - Reduced maintenance burden (single source of truth for regex patterns) - Better user visibility into what was swapped vs skipped - More robust test coverage for real-world scenarios 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent 026356b commit 0e76ec6

File tree

2 files changed

+293
-47
lines changed

2 files changed

+293
-47
lines changed

lib/demo_scripts/gem_swapper.rb

Lines changed: 87 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def initialize(gem_paths: {}, github_repos: {}, skip_build: false, watch_mode: f
3838
@skip_build = skip_build
3939
@watch_mode = watch_mode
4040
@spawned_pids = [] # Track PIDs for cleanup on failure
41+
@skipped_gems = [] # Track gems skipped due to missing paths
4142
end
4243

4344
def swap!
@@ -51,7 +52,7 @@ def swap!
5152

5253
build_local_packages! unless skip_build
5354

54-
puts '✅ Successfully swapped to local gem versions!'
55+
print_swap_summary
5556
print_next_steps
5657
rescue StandardError
5758
# Cleanup spawned watch processes on failure
@@ -811,19 +812,30 @@ def detect_default_branch(repo)
811812
end
812813

813814
def validate_local_paths!
815+
missing_paths = []
816+
814817
gem_paths.each do |gem_name, path|
815818
next if File.directory?(path)
816819

817-
error_msg = "Local path for #{gem_name} does not exist: #{path}\n\n"
818-
error_msg += "This usually means:\n"
819-
error_msg += " 1. The path in .swap-deps.yml is outdated\n"
820-
error_msg += " 2. You moved or deleted the local repository\n\n"
821-
error_msg += "To fix:\n"
822-
error_msg += " - Update .swap-deps.yml with the correct path\n"
823-
error_msg += ' - Or use --restore to restore original dependencies'
820+
# Skip validation for GitHub-managed repos (they're cloned on demand by clone_github_repos!)
821+
# Note: github_repos take precedence - if a gem is in both gem_paths and github_repos,
822+
# clone_github_repos! will overwrite the gem_paths entry with the cloned cache path
823+
next if github_repos.key?(gem_name)
824824

825-
raise Error, error_msg
825+
missing_paths << { gem_name: gem_name, path: path }
826826
end
827+
828+
return if missing_paths.empty?
829+
830+
# Show warnings for missing paths but don't fail
831+
# This allows graceful handling of deleted Conductor workspaces or stale references
832+
puts "\n⚠️ Warning: Some local paths do not exist:"
833+
missing_paths.each do |missing|
834+
puts " #{missing[:gem_name]}: #{missing[:path]}"
835+
end
836+
puts "\n These paths will be skipped. To fix:"
837+
puts ' - Update .swap-deps.yml with correct paths'
838+
puts " - Or use --restore to restore original dependencies\n\n"
827839
end
828840

829841
def clone_github_repos!
@@ -889,6 +901,7 @@ def swap_demo(demo_path)
889901
run_npm_install(demo_path) if File.exist?(package_json_path)
890902
end
891903

904+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
892905
def swap_gemfile(gemfile_path)
893906
return if gem_paths.empty? && github_repos.empty?
894907

@@ -901,6 +914,13 @@ def swap_gemfile(gemfile_path)
901914
# Skip if this gem came from GitHub (already in gem_paths via clone_github_repos!)
902915
next if github_repos.key?(gem_name)
903916

917+
# Skip missing local paths (user was already warned by validate_local_paths!)
918+
unless File.directory?(local_path)
919+
puts " ⊘ Skipping #{gem_name} - path does not exist: #{local_path}"
920+
@skipped_gems << gem_name unless @skipped_gems.include?(gem_name)
921+
next
922+
end
923+
904924
content = swap_gem_in_gemfile(content, gem_name, local_path)
905925
end
906926

@@ -916,33 +936,48 @@ def swap_gemfile(gemfile_path)
916936
puts ' ✓ Updated Gemfile'
917937
end
918938
end
939+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
940+
941+
# Strips all source specification parameters (version, path, github, git, branch, tag, ref)
942+
# from a gem line's rest string, leaving only other options (like require: false)
943+
# Returns empty string if no options remain, or the options with leading comma if any remain
944+
def strip_gem_source_params(rest)
945+
options = rest.dup
946+
options = options.sub(/^\s*,\s*(['"])[^'"]*\1/, '') # Remove version if present
947+
options = options.sub(/,\s*path:\s*(['"])[^'"]*\1/, '') # Remove path: if present
948+
options = options.sub(/,\s*github:\s*(['"])[^'"]*\1/, '') # Remove github: if present
949+
options = options.sub(/,\s*git:\s*(['"])[^'"]*\1/, '') # Remove git: if present
950+
options = options.sub(/,\s*branch:\s*(['"])[^'"]*\1/, '') # Remove branch: if present
951+
options = options.sub(/,\s*tag:\s*(['"])[^'"]*\1/, '') # Remove tag: if present
952+
options = options.sub(/,\s*ref:\s*(['"])[^'"]*\1/, '') # Remove ref: if present
953+
954+
# Return empty string if only whitespace/commas remain, otherwise return with leading comma
955+
options.strip.empty? ? '' : options
956+
end
919957

920958
def swap_gem_in_gemfile(content, gem_name, local_path)
921959
# Match variations:
922960
# gem 'name', '~> 1.0'
923961
# gem "name", "~> 1.0", require: false
924962
# gem 'name' (no version)
925963
# gem 'name', require: false (no version, with options)
926-
# BUT NOT: gem 'name', path: '...' (already swapped - skip these)
964+
# gem 'name', path: '...' (existing path - will be replaced)
965+
# gem 'name', github: '...' (existing github - will be replaced)
927966

928967
# Simple pattern: match gem lines for this gem name
929968
pattern = /^(\s*)gem\s+(['"])#{Regexp.escape(gem_name)}\2(.*)$/
930969

931-
content.gsub(pattern) do |match|
932-
# Skip if line already contains 'path:' or 'github:' - already swapped
933-
next match if match.include?('path:') || match.include?('github:')
934-
970+
content.gsub(pattern) do |_match|
935971
indent = Regexp.last_match(1)
936972
quote = Regexp.last_match(2)
937973
rest = Regexp.last_match(3)
938974

939-
# Extract options after version (if any)
940-
# Match: , 'version', options OR , options OR nothing
941-
options = rest.sub(/^\s*,\s*(['"])[^'"]*\1/, '') # Remove version if present
975+
# Extract options after stripping all source specification parameters
976+
options = strip_gem_source_params(rest)
942977

943978
# Build replacement: gem 'name', path: 'local_path' [, options...]
944979
replacement = "#{indent}gem #{quote}#{gem_name}#{quote}, path: #{quote}#{local_path}#{quote}"
945-
replacement += options unless options.strip.empty?
980+
replacement += options unless options.empty?
946981
replacement
947982
end
948983
end
@@ -951,16 +986,13 @@ def swap_gem_to_github(content, gem_name, info)
951986
# Match gem lines for this gem name
952987
pattern = /^(\s*)gem\s+(['"])#{Regexp.escape(gem_name)}\2(.*)$/
953988

954-
content.gsub(pattern) do |match|
955-
# Skip if line already contains 'path:' or 'github:' - already swapped
956-
next match if match.include?('path:') || match.include?('github:')
957-
989+
content.gsub(pattern) do |_match|
958990
indent = Regexp.last_match(1)
959991
quote = Regexp.last_match(2)
960992
rest = Regexp.last_match(3)
961993

962-
# Extract options after version (if any)
963-
options = rest.sub(/^\s*,\s*(['"])[^'"]*\1/, '') # Remove version if present
994+
# Extract options after stripping all source specification parameters
995+
options = strip_gem_source_params(rest)
964996

965997
# Use tag: for tags, branch: for branches (default to :branch if not specified)
966998
ref_type = info[:ref_type] || :branch
@@ -973,12 +1005,12 @@ def swap_gem_to_github(content, gem_name, info)
9731005
# Build replacement: gem 'name', github: 'user/repo', branch/tag: 'ref-name' [, options...]
9741006
replacement = "#{indent}gem #{quote}#{gem_name}#{quote}, github: #{quote}#{info[:repo]}#{quote}"
9751007
replacement += ", #{param_name}: #{quote}#{info[:branch]}#{quote}" unless should_omit_ref
976-
replacement += options unless options.strip.empty?
1008+
replacement += options unless options.empty?
9771009
replacement
9781010
end
9791011
end
9801012

981-
# rubocop:disable Metrics/AbcSize
1013+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
9821014
def swap_package_json(package_json_path)
9831015
npm_gems = gem_paths.select { |gem_name, _| NPM_PACKAGE_PATHS[gem_name] }
9841016
return if npm_gems.empty?
@@ -992,6 +1024,13 @@ def swap_package_json(package_json_path)
9921024
npm_package_path = NPM_PACKAGE_PATHS[gem_name]
9931025
next if npm_package_path.nil?
9941026

1027+
# Skip missing local paths (user was already warned by validate_local_paths!)
1028+
unless File.directory?(local_path)
1029+
puts " ⊘ Skipping #{gem_name} npm package - path does not exist: #{local_path}"
1030+
@skipped_gems << gem_name unless @skipped_gems.include?(gem_name)
1031+
next
1032+
end
1033+
9951034
full_npm_path = File.join(local_path, npm_package_path)
9961035
npm_name = gem_name.tr('_', '-') # Convert snake_case to kebab-case
9971036

@@ -1006,7 +1045,7 @@ def swap_package_json(package_json_path)
10061045

10071046
write_file(package_json_path, "#{JSON.pretty_generate(data)}\n") if modified
10081047
end
1009-
# rubocop:enable Metrics/AbcSize
1048+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
10101049

10111050
# rubocop:disable Metrics/MethodLength
10121051
def restore_demo(demo_path)
@@ -1282,6 +1321,13 @@ def build_local_packages!
12821321
npm_package_path = NPM_PACKAGE_PATHS[gem_name]
12831322
next if npm_package_path.nil?
12841323

1324+
# Skip missing local paths (user was already warned by validate_local_paths!)
1325+
unless File.directory?(local_path)
1326+
puts " ⊘ Skipping #{gem_name} build - path does not exist: #{local_path}"
1327+
@skipped_gems << gem_name unless @skipped_gems.include?(gem_name)
1328+
next
1329+
end
1330+
12851331
build_npm_package(gem_name, local_path, npm_package_path)
12861332
end
12871333
end
@@ -1326,6 +1372,20 @@ def build_npm_package(gem_name, gem_root, npm_subdir)
13261372
end
13271373
# rubocop:enable Metrics/MethodLength
13281374

1375+
def print_swap_summary
1376+
swapped_gems = gem_paths.keys - @skipped_gems
1377+
1378+
puts "\n✅ Successfully swapped to local gem versions!"
1379+
puts "\n📊 Summary:"
1380+
puts " ✓ Swapped: #{swapped_gems.size} gem(s)"
1381+
swapped_gems.each { |gem| puts " - #{gem}" } if swapped_gems.any?
1382+
1383+
return unless @skipped_gems.any?
1384+
1385+
puts " ⊘ Skipped: #{@skipped_gems.size} gem(s) (missing paths)"
1386+
@skipped_gems.each { |gem| puts " - #{gem}" }
1387+
end
1388+
13291389
def print_next_steps
13301390
puts "\n📝 Next steps:"
13311391
puts ' 1. Local packages are now linked via file: protocol'

0 commit comments

Comments
 (0)