ruby_workspace_manager 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,19 @@ require "open3"
4
4
 
5
5
  module Rwm
6
6
  class AffectedDetector
7
+ IGNORED_ROOT_PATTERNS = [
8
+ "*.md",
9
+ "LICENSE*",
10
+ "CHANGELOG*",
11
+ ".github/**",
12
+ ".vscode/**",
13
+ ".idea/**",
14
+ "docs/**",
15
+ ".rwm/**",
16
+ ].freeze
17
+
18
+ IGNORE_FILE = "affected_ignore"
19
+
7
20
  attr_reader :workspace, :graph, :base_branch
8
21
 
9
22
  def initialize(workspace, graph, committed_only: false, base_branch: nil)
@@ -20,7 +33,10 @@ module Rwm
20
33
 
21
34
  # If root-level files changed (outside any package), all packages are affected
22
35
  root_files = changed_files.reject { |f| file_in_any_package?(f) }
23
- unless root_files.empty?
36
+ significant_root_files = root_files.reject { |f| ignored_root_file?(f) }
37
+
38
+ unless significant_root_files.empty?
39
+ Rwm.debug("affected: significant root files changed: #{significant_root_files.join(', ')}")
24
40
  return workspace.packages
25
41
  end
26
42
 
@@ -111,5 +127,25 @@ module Rwm
111
127
  file.start_with?("#{rel_path}/")
112
128
  end
113
129
  end
130
+
131
+ def ignored_root_file?(file)
132
+ ignore_patterns.any? do |pattern|
133
+ File.fnmatch(pattern, file, File::FNM_DOTMATCH)
134
+ end
135
+ end
136
+
137
+ def ignore_patterns
138
+ patterns = IGNORED_ROOT_PATTERNS.dup
139
+ ignore_file = File.join(workspace.root, ".rwm", IGNORE_FILE)
140
+ if File.exist?(ignore_file)
141
+ File.readlines(ignore_file).each do |line|
142
+ line = line.strip
143
+ next if line.empty? || line.start_with?("#")
144
+
145
+ patterns << line
146
+ end
147
+ end
148
+ patterns
149
+ end
114
150
  end
115
151
  end
data/lib/rwm/cli.rb CHANGED
@@ -51,9 +51,16 @@ module Rwm
51
51
  const_name = COMMANDS[command_name]
52
52
  command_class = const_name.split("::").reduce(Rwm) { |mod, name| mod.const_get(name) }
53
53
  command_class.new(@argv).run
54
+ rescue Interrupt
55
+ $stderr.puts "\nInterrupted."
56
+ 130
54
57
  rescue Rwm::Error => e
55
58
  $stderr.puts "Error: #{e.message}"
56
59
  1
60
+ rescue StandardError => e
61
+ $stderr.puts "Error: #{e.message}"
62
+ Rwm.debug("#{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}")
63
+ 1
57
64
  end
58
65
 
59
66
  private
@@ -49,7 +49,7 @@ module Rwm
49
49
  end
50
50
  end
51
51
 
52
- parser.order!(@argv)
52
+ parser.parse!(@argv)
53
53
  end
54
54
  end
55
55
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "open3"
4
5
  require "optparse"
5
6
 
6
7
  module Rwm
@@ -11,6 +12,7 @@ module Rwm
11
12
 
12
13
  source "https://rubygems.org"
13
14
 
15
+ gem "rake"
14
16
  gem "ruby_workspace_manager"
15
17
  GEMFILE
16
18
 
@@ -30,7 +32,7 @@ module Rwm
30
32
  end
31
33
 
32
34
  def run
33
- root = Dir.pwd
35
+ root = detect_git_root
34
36
 
35
37
  create_directories(root)
36
38
  create_gemfile(root)
@@ -52,6 +54,13 @@ module Rwm
52
54
 
53
55
  private
54
56
 
57
+ def detect_git_root
58
+ out, _, status = Open3.capture3("git", "rev-parse", "--show-toplevel")
59
+ raise Rwm::Error, "Not inside a git repository. Run `git init` first." unless status.success?
60
+
61
+ out.chomp
62
+ end
63
+
55
64
  def parse_options
56
65
  OptionParser.new do |opts|
57
66
  opts.on("--vscode", "Generate VSCode .code-workspace file") do
@@ -1,12 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "optparse"
4
5
 
5
6
  module Rwm
6
7
  module Commands
7
8
  class New
9
+ VALID_TEST_FRAMEWORKS = %w[rspec minitest none].freeze
10
+
8
11
  def initialize(argv)
9
12
  @argv = argv
13
+ @test_framework = "rspec"
14
+ parse_options
10
15
  end
11
16
 
12
17
  def run
@@ -52,35 +57,60 @@ module Rwm
52
57
  VscodeWorkspace.new(fresh_workspace.root).generate(fresh_workspace.packages)
53
58
  end
54
59
 
60
+ def parse_options
61
+ OptionParser.new do |opts|
62
+ opts.on("--test=FRAMEWORK", VALID_TEST_FRAMEWORKS, "Test framework (#{VALID_TEST_FRAMEWORKS.join(', ')})") do |fw|
63
+ @test_framework = fw
64
+ end
65
+ end.parse!(@argv)
66
+ end
67
+
55
68
  def scaffold(pkg_path, name, type)
56
69
  source_dir = type == "lib" ? "lib" : "app"
57
70
  FileUtils.mkdir_p(File.join(pkg_path, source_dir, name))
58
- FileUtils.mkdir_p(File.join(pkg_path, "spec"))
59
71
 
60
72
  write_gemfile(pkg_path, name)
61
73
  write_gemspec(pkg_path, name, type)
62
74
  write_rakefile(pkg_path, name)
63
75
  write_entry_file(pkg_path, name, type)
64
- write_spec_helper(pkg_path)
76
+
77
+ case @test_framework
78
+ when "rspec"
79
+ FileUtils.mkdir_p(File.join(pkg_path, "spec"))
80
+ write_spec_helper(pkg_path)
81
+ when "minitest"
82
+ FileUtils.mkdir_p(File.join(pkg_path, "test"))
83
+ write_test_helper(pkg_path)
84
+ end
65
85
  end
66
86
 
67
87
  def write_gemfile(pkg_path, name)
68
- File.write(File.join(pkg_path, "Gemfile"), <<~GEMFILE)
69
- # frozen_string_literal: true
70
-
71
- source "https://rubygems.org"
88
+ test_gem_line = case @test_framework
89
+ when "rspec" then ' gem "rspec"'
90
+ when "minitest" then ' gem "minitest"'
91
+ end
72
92
 
73
- gemspec
74
-
75
- group :development, :test do
76
- gem "rake"
77
- gem "rspec"
78
- gem "ruby_workspace_manager"
79
- end
93
+ lines = [
94
+ '# frozen_string_literal: true',
95
+ '',
96
+ 'source "https://rubygems.org"',
97
+ '',
98
+ 'gemspec',
99
+ '',
100
+ 'group :development, :test do',
101
+ ' gem "rake"',
102
+ ]
103
+ lines << test_gem_line if test_gem_line
104
+ lines.concat([
105
+ ' gem "ruby_workspace_manager"',
106
+ 'end',
107
+ '',
108
+ 'require "rwm/gemfile"',
109
+ '# rwm_lib "some_dependency"',
110
+ '',
111
+ ])
80
112
 
81
- require "rwm/gemfile"
82
- # rwm_lib "some_dependency"
83
- GEMFILE
113
+ File.write(File.join(pkg_path, "Gemfile"), lines.join("\n"))
84
114
  end
85
115
 
86
116
  def write_gemspec(pkg_path, name, type)
@@ -106,21 +136,44 @@ module Rwm
106
136
  end
107
137
 
108
138
  def write_rakefile(pkg_path, name)
109
- File.write(File.join(pkg_path, "Rakefile"), <<~RAKEFILE)
110
- # frozen_string_literal: true
139
+ lines = [
140
+ '# frozen_string_literal: true',
141
+ '',
142
+ 'require "rwm/rake"',
143
+ '',
144
+ ]
111
145
 
112
- require "rwm/rake"
146
+ case @test_framework
147
+ when "rspec"
148
+ lines.concat([
149
+ 'cacheable_task :spec do',
150
+ ' sh "bundle exec rspec"',
151
+ 'end',
152
+ '',
153
+ ])
154
+ when "minitest"
155
+ lines.concat([
156
+ 'cacheable_task :test do',
157
+ ' sh "bundle exec ruby -Ilib:test -e \'Dir.glob(\"test/**/*_test.rb\").each { |f| require_relative f }\'"',
158
+ 'end',
159
+ '',
160
+ ])
161
+ end
113
162
 
114
- cacheable_task :spec do
115
- sh "bundle exec rspec"
116
- end
163
+ lines.concat([
164
+ "task :bootstrap do",
165
+ " puts \"Add bootstrap steps for #{name} here.\"",
166
+ "end",
167
+ "",
168
+ ])
117
169
 
118
- task :bootstrap do
119
- puts "Add bootstrap steps for #{name} here."
120
- end
170
+ if @test_framework != "none"
171
+ task_name = @test_framework == "rspec" ? ":spec" : ":test"
172
+ lines << "task default: #{task_name}"
173
+ lines << ""
174
+ end
121
175
 
122
- task default: :spec
123
- RAKEFILE
176
+ File.write(File.join(pkg_path, "Rakefile"), lines.join("\n"))
124
177
  end
125
178
 
126
179
  def write_entry_file(pkg_path, name, type)
@@ -145,6 +198,14 @@ module Rwm
145
198
  RUBY
146
199
  end
147
200
 
201
+ def write_test_helper(pkg_path)
202
+ File.write(File.join(pkg_path, "test", "test_helper.rb"), <<~RUBY)
203
+ # frozen_string_literal: true
204
+
205
+ require "minitest/autorun"
206
+ RUBY
207
+ end
208
+
148
209
  def camelize(name)
149
210
  name.split("_").map(&:capitalize).join
150
211
  end
@@ -62,6 +62,7 @@ module Rwm
62
62
  # Auto-detect cacheable tasks unless --no-cache
63
63
  cache = TaskCache.new(workspace, graph) unless @no_cache
64
64
  if cache
65
+ cache.preload_declarations(runnable)
65
66
  cacheable, not_cacheable = runnable.partition { |pkg| cache.cacheable?(pkg, task) }
66
67
  cached, uncached = cacheable.partition { |pkg| cache.cached?(pkg, task) }
67
68
  cached.each { |pkg| puts "[#{pkg.name}] cached" }
@@ -90,35 +91,36 @@ module Rwm
90
91
  # Store cache for successful cacheable packages
91
92
  if cache
92
93
  runner.results.each do |result|
93
- next unless result.success
94
- next if result.skipped
94
+ next unless result.passed?
95
95
 
96
96
  pkg = workspace.find_package(result.package_name)
97
97
  cache.store(pkg, task) if cache.cacheable?(pkg, task)
98
98
  end
99
99
  end
100
100
 
101
- passed, rest = runner.results.partition { |r| r.success && !r.skipped }
102
- failed, skipped = rest.partition { |r| !r.success }
103
- skipped_from_rest = runner.results.select(&:skipped)
101
+ passed = runner.results.count(&:passed?)
102
+ failed_results = runner.results.select { |r| r.failed? || r.errored? }
103
+ skipped = runner.results.count { |r| r.skipped? || r.dep_skipped? }
104
104
 
105
105
  total = runner.results.size
106
106
  parts = []
107
- parts << "#{passed.size} passed" unless passed.empty?
108
- parts << "#{failed.size} failed" unless failed.empty?
109
- parts << "#{skipped_from_rest.size} skipped" unless skipped_from_rest.empty?
107
+ parts << "#{passed} passed" unless passed.zero?
108
+ parts << "#{failed_results.size} failed" unless failed_results.empty?
109
+ parts << "#{skipped} skipped" unless skipped.zero?
110
110
 
111
111
  puts
112
112
  puts "#{total} package(s): #{parts.join(", ")}."
113
113
 
114
- Rwm.debug("passed: #{passed.map(&:package_name).join(", ")}") unless passed.empty?
115
- Rwm.debug("skipped (no matching task): #{skipped_from_rest.map(&:package_name).join(", ")}") unless skipped_from_rest.empty?
114
+ passed_results = runner.results.select(&:passed?)
115
+ skipped_results = runner.results.select { |r| r.skipped? || r.dep_skipped? }
116
+ Rwm.debug("passed: #{passed_results.map(&:package_name).join(", ")}") unless passed_results.empty?
117
+ Rwm.debug("skipped (no matching task): #{skipped_results.map(&:package_name).join(", ")}") unless skipped_results.empty?
116
118
 
117
- if failed.empty?
119
+ if failed_results.empty?
118
120
  0
119
121
  else
120
122
  $stderr.puts "Failed:"
121
- failed.each { |r| $stderr.puts " - #{r.package_name}" }
123
+ failed_results.each { |r| $stderr.puts " - #{r.package_name}" }
122
124
  1
123
125
  end
124
126
  end
@@ -150,7 +152,7 @@ module Rwm
150
152
  end
151
153
  end
152
154
 
153
- parser.order!(@argv)
155
+ parser.parse!(@argv)
154
156
  end
155
157
  end
156
158
  end
@@ -70,17 +70,17 @@ module Rwm
70
70
  return [] if @packages.empty?
71
71
 
72
72
  remaining = @packages.keys.dup
73
+ placed = Set.new
73
74
  levels = []
74
75
 
75
76
  until remaining.empty?
76
- # Find packages whose deps are all already placed in earlier levels
77
- placed = levels.flatten
78
77
  level = remaining.select do |name|
79
78
  dependencies(name).all? { |dep| placed.include?(dep) }
80
79
  end
81
80
 
82
81
  raise CycleError, [["Unable to resolve execution levels — possible cycle"]] if level.empty?
83
82
 
83
+ level.each { |name| placed.add(name) }
84
84
  levels << level.sort
85
85
  remaining -= level
86
86
  end
@@ -103,7 +103,7 @@ module Rwm
103
103
  end
104
104
 
105
105
  Rwm.debug("graph: loading from cache at #{path}")
106
- data = JSON.parse(File.read(path))
106
+ data = JSON.parse(read_locked(path))
107
107
  graph = new
108
108
 
109
109
  workspace.packages.each { |pkg| graph.add_package(pkg) }
@@ -126,7 +126,16 @@ module Rwm
126
126
  packages.any? { |pkg| File.mtime(pkg.gemfile_path) > graph_mtime }
127
127
  end
128
128
 
129
- private_class_method :stale?, :build_and_save
129
+ def self.read_locked(path)
130
+ File.open(path, "r") do |f|
131
+ f.flock(File::LOCK_SH)
132
+ f.read
133
+ end
134
+ rescue Errno::ENOTSUP
135
+ File.read(path)
136
+ end
137
+
138
+ private_class_method :stale?, :build_and_save, :read_locked
130
139
 
131
140
  # Build graph from a workspace by parsing all Gemfiles
132
141
  def self.build(workspace)
@@ -158,7 +167,7 @@ module Rwm
158
167
  @workspace_root = workspace_root
159
168
  dir = File.dirname(path)
160
169
  FileUtils.mkdir_p(dir)
161
- File.write(path, JSON.pretty_generate(to_json_data) + "\n")
170
+ write_locked(path, JSON.pretty_generate(to_json_data) + "\n")
162
171
  end
163
172
 
164
173
  def to_dot
@@ -200,6 +209,15 @@ module Rwm
200
209
 
201
210
  private
202
211
 
212
+ def write_locked(path, content)
213
+ File.open(path, File::CREAT | File::WRONLY | File::TRUNC) do |f|
214
+ f.flock(File::LOCK_EX)
215
+ f.write(content)
216
+ end
217
+ rescue Errno::ENOTSUP
218
+ File.write(path, content)
219
+ end
220
+
203
221
  # TSort interface
204
222
  def tsort_each_node(&block)
205
223
  @packages.each_key(&block)
data/lib/rwm/gemfile.rb CHANGED
@@ -38,7 +38,7 @@ module Rwm
38
38
  return if @rwm_resolved.include?(name)
39
39
 
40
40
  @rwm_resolved.add(name)
41
- Rwm.resolved_libs.add(name)
41
+ Rwm.resolved_libs.add(name) unless @rwm_scanning
42
42
 
43
43
  path = File.join(rwm_workspace_root, "libs", name)
44
44
  gem(name, **opts, path: path)
@@ -54,6 +54,7 @@ module Rwm
54
54
 
55
55
  def scan_transitive_deps(gemfile_path)
56
56
  sandbox = Bundler::Dsl.new
57
+ sandbox.instance_variable_set(:@rwm_scanning, true)
57
58
  sandbox.eval_gemfile(gemfile_path)
58
59
 
59
60
  libs_prefix = File.join(rwm_workspace_root, "libs") + "/"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require "etc"
4
5
  require "fileutils"
5
6
  require "json"
6
7
  require "open3"
@@ -24,6 +25,7 @@ module Rwm
24
25
  @cache_dir = File.join(workspace.root, ".rwm", "cache")
25
26
  @content_hashes = {}
26
27
  @cache_declarations = {}
28
+ @declarations_mutex = Mutex.new
27
29
  end
28
30
 
29
31
  # Returns true if the task is declared cacheable in the package's Rakefile
@@ -98,19 +100,46 @@ module Rwm
98
100
  @content_hashes[package.name] = digest.hexdigest
99
101
  end
100
102
 
103
+ # Preload cache declarations for multiple packages in parallel.
104
+ # Warms the memoization hash so subsequent cacheable?/cached? calls are instant.
105
+ def preload_declarations(packages)
106
+ pending = packages.reject { |pkg| @cache_declarations.key?(pkg.name) }
107
+ return if pending.empty?
108
+
109
+ Rwm.debug("cache declarations: preloading #{pending.size} package(s) in parallel")
110
+ concurrency = [Etc.nprocessors, pending.size].min
111
+ threads = []
112
+
113
+ pending.each_slice((pending.size.to_f / concurrency).ceil) do |batch|
114
+ threads << Thread.new do
115
+ batch.each { |pkg| cache_declarations(pkg) }
116
+ end
117
+ end
118
+
119
+ threads.each(&:join)
120
+ end
121
+
101
122
  # Discover cacheable task declarations by running `bundle exec rake rwm:cache_config`
102
123
  def cache_declarations(package)
103
- return @cache_declarations[package.name] if @cache_declarations.key?(package.name)
124
+ @declarations_mutex.synchronize do
125
+ return @cache_declarations[package.name] if @cache_declarations.key?(package.name)
126
+ end
104
127
 
105
128
  Rwm.debug("cache declarations: discovering for #{package.name}")
106
129
  output, _, status = Open3.capture3("bundle", "exec", "rake", "rwm:cache_config", chdir: package.path)
107
- @cache_declarations[package.name] = if status.success? && !output.strip.empty?
108
- JSON.parse(output.strip)
109
- else
110
- {}
111
- end
130
+ result = if status.success? && !output.strip.empty?
131
+ JSON.parse(output.strip)
132
+ else
133
+ {}
134
+ end
135
+
136
+ @declarations_mutex.synchronize do
137
+ @cache_declarations[package.name] = result
138
+ end
112
139
  rescue JSON::ParserError
113
- @cache_declarations[package.name] = {}
140
+ @declarations_mutex.synchronize do
141
+ @cache_declarations[package.name] = {}
142
+ end
114
143
  end
115
144
 
116
145
  private
@@ -5,9 +5,20 @@ require "etc"
5
5
 
6
6
  module Rwm
7
7
  class TaskRunner
8
- Result = Struct.new(:package_name, :task, :success, :output, :skipped, keyword_init: true)
8
+ Result = Struct.new(:package_name, :task, :status, :output, keyword_init: true) do
9
+ def passed? = status == :passed
10
+ def failed? = status == :failed
11
+ def skipped? = status == :skipped
12
+ def dep_skipped? = status == :dep_skipped
13
+ def errored? = status == :errored
14
+ def success? = passed? || skipped?
15
+ end
9
16
 
10
- NO_TASK_PATTERN = /Don't know how to build task/
17
+ NO_TASK_PATTERN = /
18
+ don.t\s+know\s+how\s+to\s+build\s+task
19
+ |
20
+ rake\s+--tasks
21
+ /ix
11
22
 
12
23
  attr_reader :results
13
24
 
@@ -60,28 +71,38 @@ module Rwm
60
71
 
61
72
  pending.delete(pkg)
62
73
  running[pkg.name] = Thread.new do
63
- result = run_single(pkg, &command_proc)
64
- mutex.synchronize do
65
- @results << result
66
- running.delete(pkg.name)
67
- if result.success
68
- completed << pkg.name
69
- else
70
- skip_names = @graph.transitive_dependents(pkg.name)
71
- .select { |n| package_names.include?(n) }
72
- skip_names.each do |name|
73
- skip_pkg = pending.find { |p| p.name == name }
74
- if skip_pkg
75
- pending.delete(skip_pkg)
76
- skipped << name
77
- @results << Result.new(
78
- package_name: name, task: "skipped",
79
- success: false, output: "Skipped due to failed dependency: #{pkg.name}"
80
- )
74
+ begin
75
+ result = run_single(pkg, &command_proc)
76
+ rescue => e
77
+ result = Result.new(
78
+ package_name: pkg.name, task: "error",
79
+ status: :errored, output: "Error: #{e.class}: #{e.message}"
80
+ )
81
+ ensure
82
+ next unless result # thread was killed before completing
83
+
84
+ mutex.synchronize do
85
+ @results << result
86
+ running.delete(pkg.name)
87
+ if result.success?
88
+ completed << pkg.name
89
+ else
90
+ skip_names = @graph.transitive_dependents(pkg.name)
91
+ .select { |n| package_names.include?(n) }
92
+ skip_names.each do |name|
93
+ skip_pkg = pending.find { |p| p.name == name }
94
+ if skip_pkg
95
+ pending.delete(skip_pkg)
96
+ skipped << name
97
+ @results << Result.new(
98
+ package_name: name, task: "skipped",
99
+ status: :dep_skipped, output: "Skipped due to failed dependency: #{pkg.name}"
100
+ )
101
+ end
81
102
  end
82
103
  end
104
+ condition.broadcast
83
105
  end
84
- condition.broadcast
85
106
  end
86
107
  end
87
108
  end
@@ -107,11 +128,11 @@ module Rwm
107
128
  end
108
129
 
109
130
  def success?
110
- @results.all?(&:success)
131
+ @results.none? { |r| r.failed? || r.errored? }
111
132
  end
112
133
 
113
134
  def failed_results
114
- @results.select { |r| !r.success }
135
+ @results.select { |r| r.failed? || r.errored? }
115
136
  end
116
137
 
117
138
  private
@@ -135,13 +156,12 @@ module Rwm
135
156
 
136
157
  # Detect "task not found" and treat as skipped, not failed
137
158
  if !status.success? && stderr.match?(NO_TASK_PATTERN)
138
- Rwm.debug("#{pkg.name}: task not found, skipping")
159
+ Rwm.debug("#{pkg.name}: task not found (matched: #{stderr.lines.first&.chomp})")
139
160
  return Result.new(
140
161
  package_name: pkg.name,
141
162
  task: cmd.join(" "),
142
- success: true,
143
- output: "",
144
- skipped: true
163
+ status: :skipped,
164
+ output: ""
145
165
  )
146
166
  end
147
167
 
@@ -154,7 +174,7 @@ module Rwm
154
174
  Result.new(
155
175
  package_name: pkg.name,
156
176
  task: cmd.join(" "),
157
- success: status.success?,
177
+ status: status.success? ? :passed : :failed,
158
178
  output: output
159
179
  )
160
180
  end
data/lib/rwm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rwm
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_workspace_manager
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Siddharth Bhatt