ruby_workspace_manager 0.6.1 → 0.6.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7292181b24a08aeaed78ff41ee3fe3d3284185af153d5c151cd64f6f2bda6948
4
- data.tar.gz: 41c43c206dbfbbd9ac68f1d9378bab8820d17386dfa8f025be66bc4eeb628a35
3
+ metadata.gz: abb2b2c2c95382842ff412711726e5180b6b044b09278f74fd41bce2d29ae7e0
4
+ data.tar.gz: 2370c792ceb61d94619822c8b7a6f63277284f178e72d6a3539f782496d64ba1
5
5
  SHA512:
6
- metadata.gz: 6c72d4000b74f07310757f950e9a081eac2697574c9600bdcfd6d0d1aa1fe0b86b4a2a40b11ef9e9ce5df7762e72fe505e4c88b653bfde16e4d2df8fd343ce07
7
- data.tar.gz: 3c40d200f9bf6de9da5f8f45d0147b57b395d95507d3d61e449abcf0317d27cd629b544a4289b711948ad19453994d6cf9fe790f24434d6a650510027b1ed5a6
6
+ metadata.gz: a6746e8ced0a14de677e83de564c7477e89b0f2519a37447dc1a94dbe28acc3cef2b848c1b3bb2604afe24cd399050d677eceb2dbea194a87163c564df1cada4
7
+ data.tar.gz: 076e897c277f1074f5c4eef7ee834e5c1920050569783e2a84bac836aa9bb4c82aef182390294663068a24fd6690c172e6946b38ea2e29f0bc9289803ac47283
data/README.md CHANGED
@@ -211,6 +211,13 @@ rwm_lib "auth", require: false
211
211
 
212
212
  There is no `rwm_app` helper. Applications are leaf nodes — nothing should depend on them.
213
213
 
214
+ `rwm_lib` validates that the library directory exists. If you reference a library that hasn't been created yet, you'll get a clear error:
215
+
216
+ ```
217
+ rwm_lib 'payments': no library found at libs/payments.
218
+ Libraries must live in libs/. Create one with: rwm new lib payments
219
+ ```
220
+
214
221
  You can also use raw `gem ... path:` syntax directly. Both work identically for dependency detection.
215
222
 
216
223
  ### Transitive resolution
@@ -303,6 +310,15 @@ rwm run spec --buffered
303
310
 
304
311
  When a package fails, its transitive dependents are immediately skipped. Unrelated packages continue running. The exit code is 0 if all packages pass, 1 if any fail.
305
312
 
313
+ The summary distinguishes between skip reasons:
314
+
315
+ ```
316
+ 5 package(s): 2 passed, 1 failed, 1 skipped (dep failed), 1 skipped (no task).
317
+ ```
318
+
319
+ - **skipped (dep failed)** — a dependency failed, so this package was not attempted
320
+ - **skipped (no task)** — the package's Rakefile doesn't define the requested task
321
+
306
322
  ## Task caching
307
323
 
308
324
  ### Why caching matters
@@ -528,6 +544,8 @@ rwm affected --base develop
528
544
  rwm run spec --affected --base develop
529
545
  ```
530
546
 
547
+ If the provided `--base` ref doesn't exist, RWM errors immediately instead of silently returning no affected packages.
548
+
531
549
  ## Bootstrap and daily workflow
532
550
 
533
551
  ### What bootstrap does
@@ -544,6 +562,8 @@ rwm run spec --affected --base develop
544
562
 
545
563
  Both `rwm init` and `rwm bootstrap` are idempotent.
546
564
 
565
+ **Note on parallel installs:** Step 4 runs `bundle install` concurrently across packages. If your packages share a gem installation directory (the default), you may see Bundler log `Waiting for another process to let go of lock`. This is normal — Bundler serializes writes to the shared directory automatically. On large monorepos with many packages, this can slow down bootstrap. If this becomes a bottleneck, consider using `BUNDLE_PATH` per-package or running bootstrap sequentially.
566
+
547
567
  ### The bootstrap rake task
548
568
 
549
569
  Every scaffolded package includes an empty `bootstrap` task. This is where package-specific setup belongs:
@@ -610,60 +630,135 @@ Exits 0 on pass, 1 on violation. The pre-push hook runs this automatically.
610
630
 
611
631
  ## Rails and Zeitwerk
612
632
 
613
- Rails uses [Zeitwerk](https://github.com/fxn/zeitwerk) for autoloading. Zeitwerk overrides `Kernel#require`, so `require "auth"` in a controller will fail with a `LoadError` even though the gem is installed. The fix: require workspace libs before Zeitwerk starts.
633
+ ### How workspace libs work in Rails
634
+
635
+ Workspace libs declared via `rwm_lib` are path gems. The standard Rails boot sequence handles them automatically:
614
636
 
615
- ### Setup
637
+ 1. `config/boot.rb` calls `Bundler.setup` — adds all gem `lib/` directories to `$LOAD_PATH`
638
+ 2. `config/application.rb` calls `Bundler.require(*Rails.groups)` — auto-requires every gem, including workspace libs and their transitive deps
639
+ 3. `config/environment.rb` calls `Rails.application.initialize!` — Zeitwerk activates for the app's own code
616
640
 
617
- **1. Gemfile**declare workspace deps with `rwm_lib`:
641
+ By the time Zeitwerk starts in step 3, workspace libs are already loaded as plain Ruby modules. Zeitwerk never touches them it only manages directories in `config.autoload_paths`.
642
+
643
+ **No special setup is needed in `application.rb`.** A standard Rails template works:
618
644
 
619
645
  ```ruby
620
646
  # apps/web/Gemfile
647
+ require "rwm/gemfile"
648
+
621
649
  source "https://rubygems.org"
622
650
  gemspec
623
651
 
624
- require "rwm/gemfile"
625
-
626
652
  rwm_lib "auth" # transitive deps resolved automatically
627
653
  ```
628
654
 
629
- `ruby_workspace_manager` must be a runtime dependency (not in `:development` group) for Rails apps:
655
+ ```ruby
656
+ # apps/web/config/application.rb
657
+ require_relative "boot"
658
+ require "rails/all"
659
+ Bundler.require(*Rails.groups)
660
+
661
+ module Web
662
+ class Application < Rails::Application
663
+ config.load_defaults 8.0
664
+ end
665
+ end
666
+ ```
667
+
668
+ That's it. `Bundler.require` loads `auth` and all of its transitive workspace dependencies. No manual `Rwm.require_libs`, no ordering tricks.
669
+
670
+ ### A note on Zeitwerk
671
+
672
+ > [!IMPORTANT]
673
+ > **Correction (v0.6.2):** Documentation in v0.6.1 and earlier incorrectly stated that Zeitwerk overrides `Kernel#require`. This was wrong. Zeitwerk uses `Module#autoload` and `const_missing` to lazily load files from `config.autoload_paths`. A plain `require "auth"` (from `Bundler.require` or anywhere else) works normally at any point during the boot sequence — Zeitwerk does not intercept it.
674
+
675
+ ### The practical lib workflow
676
+
677
+ **Develop inside your Rails app first.** While a feature is in active development, keep the code in your Rails app's `app/` directory where Zeitwerk gives you hot reloading for free. Change a file, refresh the page, see the result.
678
+
679
+ **Extract when stable.** When the code has solidified — the interface is settled, multiple apps could use it, you're not changing it every day — extract it into a workspace lib. This is the natural monorepo rhythm: apps are where you experiment, libs are where you consolidate.
680
+
681
+ At extraction time, choose how the lib is structured.
682
+
683
+ ### Traditional structure (the default)
684
+
685
+ This is what `rwm new lib` scaffolds. The lib's entry point loads all sub-files eagerly with `require_relative`:
630
686
 
631
687
  ```ruby
632
- # apps/web/web.gemspec
633
- spec.add_dependency "ruby_workspace_manager"
688
+ # libs/auth/lib/auth.rb
689
+ require_relative "auth/token"
690
+ require_relative "auth/user"
691
+
692
+ module Auth
693
+ VERSION = "0.1.0"
694
+ end
634
695
  ```
635
696
 
636
- **2. application.rb**require workspace libs before Rails loads:
697
+ **Pros:** Works everywhere Rails, non-Rails, any Ruby app. Simple. Standard gem structure.
698
+
699
+ **Cons:** No hot reloading in Rails development. After changing a lib file, you restart the server. This is fine for stable extracted code — you're not changing it often.
700
+
701
+ This is the right choice for most workspace libs.
702
+
703
+ ### Zeitwerk-compatible structure (opt-in)
704
+
705
+ Choose this when you're still actively iterating on a lib **and** multiple Rails apps consume it. The lib follows Zeitwerk naming conventions — one constant per file, no `require_relative`:
637
706
 
638
707
  ```ruby
639
- # apps/web/config/application.rb
640
- require_relative "boot"
708
+ # libs/auth/lib/auth.rb
709
+ module Auth
710
+ end
641
711
 
642
- require "rwm/rails"
643
- Rwm.require_libs
712
+ # libs/auth/lib/auth/token.rb — defines Auth::Token
713
+ # libs/auth/lib/auth/user.rb — defines Auth::User
714
+ # Zeitwerk auto-discovers these. No require lines needed.
715
+ ```
644
716
 
645
- require "rails"
646
- require "action_controller/railtie"
717
+ Each consuming Rails app opts in by adding the lib to its autoload paths and telling Bundler not to auto-require it:
647
718
 
719
+ ```ruby
720
+ # apps/web/Gemfile
721
+ rwm_lib "auth", require: false # Bundler won't auto-require
722
+ ```
723
+
724
+ ```ruby
725
+ # apps/web/config/application.rb
648
726
  module Web
649
727
  class Application < Rails::Application
650
- config.load_defaults 8.0
728
+ config.autoload_paths << Rwm.lib_path("auth")
729
+ config.eager_load_paths << Rwm.lib_path("auth")
651
730
  end
652
731
  end
653
732
  ```
654
733
 
655
- `Rwm.require_libs` requires exactly the libs that `rwm_lib` resolved in the Gemfile direct and transitive. After this line, workspace libraries are loaded as plain Ruby modules. Zeitwerk takes over for the app's own code and never touches them.
734
+ Now Zeitwerk manages `auth` lazy loading in development (with hot reloading), eager loading in production. Changes to lib files are picked up on the next request without restarting the server.
656
735
 
657
- ### Why this ordering matters
736
+ **Trade-offs:**
658
737
 
659
- The Rails boot sequence: `config/boot.rb` runs `Bundler.setup` (adds gems to load path) `config/application.rb` (your code, then Rails) → `config/environment.rb` (Zeitwerk activates). `Rwm.require_libs` must run after Bundler.setup and before `require "rails"`.
738
+ - All consumer apps must add the lib to their autoload paths this is a per-app decision
739
+ - The lib cannot use `require_relative` for its own files (Zeitwerk must control loading)
740
+ - Non-Rails consumers need a different loading strategy (e.g., `Zeitwerk::Loader.for_gem` or a `Dir.glob` require)
660
741
 
661
742
  ### What doesn't work
662
743
 
663
- - `require "auth"` inside a controller or model — Zeitwerk is already active.
664
- - Adding workspace libs to `config.autoload_paths` — they have their own structure.
744
+ **Mixing `Bundler.require` and `autoload_paths` for the same lib.** If `Bundler.require` loads a lib (the default) and you also add it to `config.autoload_paths`, the lib's constants are loaded twice once eagerly by Bundler, once lazily by Zeitwerk. Reloading breaks because Zeitwerk didn't control the initial load. Pick one or the other per lib.
745
+
746
+ **Using `require_relative` inside a Zeitwerk-managed lib.** Initial loading works fine — Zeitwerk tolerates other loading mechanisms. But after a Zeitwerk reload cycle (in development), files loaded by `require_relative` are still in `$LOADED_FEATURES`. Ruby's `require_relative` sees them as already loaded and skips them. The constants were removed by Zeitwerk's reload but never re-defined. Result: `NameError`.
747
+
748
+ ### `Rwm.require_libs` — when you need it
749
+
750
+ For standard Rails apps, `Bundler.require` handles everything. `Rwm.require_libs` exists for edge cases:
751
+
752
+ - Non-standard Rails setups that don't call `Bundler.require`
753
+ - Non-Rails apps that want to load all workspace libs in one call
754
+ - Explicit control over when workspace libs are loaded
755
+
756
+ ```ruby
757
+ require "rwm/rails"
758
+ Rwm.require_libs # requires all libs resolved by rwm_lib, idempotent
759
+ ```
665
760
 
666
- Non-Rails apps don't have this problem and can `require` workspace libs defined in their Gemfile anywhere in their code.
761
+ Non-Rails apps don't need any of this `require` workspace libs from your Gemfile anywhere in your code, as with any gem.
667
762
 
668
763
  ## VSCode integration
669
764
 
data/bin/rwm CHANGED
@@ -3,4 +3,4 @@
3
3
 
4
4
  require "rwm"
5
5
 
6
- Rwm::CLI.run(ARGV)
6
+ exit(Rwm::CLI.run(ARGV))
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rwm"
@@ -24,6 +24,7 @@ module Rwm
24
24
  @graph = graph
25
25
  @committed_only = committed_only
26
26
  @base_branch = base_branch || detect_base_branch
27
+ validate_base_branch! if base_branch
27
28
  end
28
29
 
29
30
  # Returns packages directly changed + their transitive dependents
@@ -57,6 +58,13 @@ module Rwm
57
58
 
58
59
  private
59
60
 
61
+ def validate_base_branch!
62
+ _, _, status = Open3.capture3("git", "-C", workspace.root, "rev-parse", "--verify", "#{@base_branch}^{commit}")
63
+ return if status.success?
64
+
65
+ raise Rwm::Error, "Base ref '#{@base_branch}' does not exist. Check the branch name or pass a valid --base ref."
66
+ end
67
+
60
68
  def detect_base_branch
61
69
  # Try to read the remote's default branch
62
70
  ref, _, status = Open3.capture3("git", "-C", workspace.root, "symbolic-ref", "refs/remotes/origin/HEAD")
@@ -23,6 +23,15 @@ module Rwm
23
23
  puts graph.to_dot
24
24
  when :mermaid
25
25
  puts graph.to_mermaid
26
+ else
27
+ # Show a brief package listing when no format is requested
28
+ unless graph.packages.empty?
29
+ graph.packages.each_value do |pkg|
30
+ deps = graph.edges[pkg.name] || []
31
+ dep_str = deps.empty? ? "" : " → #{deps.join(", ")}"
32
+ puts " #{pkg.type == "lib" ? "lib" : "app"}/#{pkg.name}#{dep_str}"
33
+ end
34
+ end
26
35
  end
27
36
 
28
37
  0
@@ -100,21 +100,25 @@ module Rwm
100
100
 
101
101
  passed = runner.results.count(&:passed?)
102
102
  failed_results = runner.results.select { |r| r.failed? || r.errored? }
103
- skipped = runner.results.count { |r| r.skipped? || r.dep_skipped? }
103
+ no_task = runner.results.count(&:skipped?)
104
+ dep_failed = runner.results.count(&:dep_skipped?)
104
105
 
105
106
  total = runner.results.size
106
107
  parts = []
107
108
  parts << "#{passed} passed" unless passed.zero?
108
109
  parts << "#{failed_results.size} failed" unless failed_results.empty?
109
- parts << "#{skipped} skipped" unless skipped.zero?
110
+ parts << "#{dep_failed} skipped (dep failed)" unless dep_failed.zero?
111
+ parts << "#{no_task} skipped (no task)" unless no_task.zero?
110
112
 
111
113
  puts
112
114
  puts "#{total} package(s): #{parts.join(", ")}."
113
115
 
114
116
  passed_results = runner.results.select(&:passed?)
115
- skipped_results = runner.results.select { |r| r.skipped? || r.dep_skipped? }
117
+ no_task_results = runner.results.select(&:skipped?)
118
+ dep_skipped_results = runner.results.select(&:dep_skipped?)
116
119
  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?
120
+ Rwm.debug("skipped (no matching task): #{no_task_results.map(&:package_name).join(", ")}") unless no_task_results.empty?
121
+ Rwm.debug("skipped (dep failed): #{dep_skipped_results.map(&:package_name).join(", ")}") unless dep_skipped_results.empty?
118
122
 
119
123
  if failed_results.empty?
120
124
  0
@@ -103,13 +103,30 @@ module Rwm
103
103
  end
104
104
 
105
105
  Rwm.debug("graph: loading from cache at #{path}")
106
- data = JSON.parse(read_locked(path))
106
+ begin
107
+ data = JSON.parse(read_locked(path))
108
+ rescue Errno::ENOENT
109
+ Rwm.debug("graph: cache file disappeared, rebuilding")
110
+ return build_and_save(workspace)
111
+ rescue JSON::ParserError
112
+ Rwm.debug("graph: cache file contains invalid JSON, rebuilding")
113
+ return build_and_save(workspace)
114
+ end
115
+
107
116
  graph = new
108
117
 
109
118
  workspace.packages.each { |pkg| graph.add_package(pkg) }
110
119
 
111
120
  data["edges"]&.each do |name, deps|
112
- deps.each { |dep| graph.add_edge(name, dep) }
121
+ next unless graph.packages.key?(name)
122
+
123
+ deps.each do |dep|
124
+ if graph.packages.key?(dep)
125
+ graph.add_edge(name, dep)
126
+ else
127
+ Rwm.debug("graph: skipping stale edge #{name} -> #{dep} (package removed)")
128
+ end
129
+ end
113
130
  end
114
131
 
115
132
  graph
data/lib/rwm/gemfile.rb CHANGED
@@ -17,17 +17,33 @@ require "set"
17
17
 
18
18
  module Rwm
19
19
  @resolved_libs = Set.new
20
+ @workspace_root = nil
20
21
 
21
22
  def self.resolved_libs
22
23
  @resolved_libs
23
24
  end
24
25
 
26
+ def self.workspace_root
27
+ @workspace_root
28
+ end
29
+
30
+ def self.workspace_root=(path)
31
+ @workspace_root = path
32
+ end
33
+
34
+ def self.lib_path(name)
35
+ raise "rwm: workspace root not set (was rwm_lib used in the Gemfile?)" unless @workspace_root
36
+
37
+ File.join(@workspace_root, "libs", name.to_s, "lib")
38
+ end
39
+
25
40
  module GemfileDsl
26
41
  def rwm_workspace_root
27
42
  @rwm_workspace_root ||= begin
28
43
  out, _, status = Open3.capture3("git", "rev-parse", "--show-toplevel")
29
44
  root = status.success? ? out.strip : ""
30
45
  raise "rwm: not inside a git repository" if root.empty?
46
+ Rwm.workspace_root ||= root
31
47
  root
32
48
  end
33
49
  end
@@ -37,10 +53,16 @@ module Rwm
37
53
  @rwm_resolved ||= Set.new
38
54
  return if @rwm_resolved.include?(name)
39
55
 
56
+ path = File.join(rwm_workspace_root, "libs", name)
57
+
58
+ unless File.directory?(path)
59
+ raise "rwm_lib '#{name}': no library found at libs/#{name}. " \
60
+ "Libraries must live in libs/. Create one with: rwm new lib #{name}"
61
+ end
62
+
40
63
  @rwm_resolved.add(name)
41
64
  Rwm.resolved_libs.add(name) unless @rwm_scanning
42
65
 
43
- path = File.join(rwm_workspace_root, "libs", name)
44
66
  gem(name, **opts, path: path)
45
67
 
46
68
  # Resolve transitive workspace deps from the target lib's Gemfile
data/lib/rwm/rails.rb CHANGED
@@ -2,17 +2,28 @@
2
2
 
3
3
  # Rails integration for RWM workspaces.
4
4
  #
5
- # Usage in config/application.rb:
5
+ # For standard Rails apps, Bundler.require handles workspace libs
6
+ # automatically — no manual require_libs call is needed.
7
+ #
8
+ # This file is for non-standard setups or explicit control:
6
9
  #
7
- # require_relative "boot"
8
10
  # require "rwm/rails"
9
11
  # Rwm.require_libs
10
- # require "rails"
11
12
 
12
13
  require "rwm/gemfile"
13
14
 
14
15
  module Rwm
16
+ @libs_required = false
17
+
18
+ def self.libs_required?
19
+ @libs_required
20
+ end
21
+
15
22
  def self.require_libs
16
- self.resolved_libs.each { |name| require name }
23
+ return if @libs_required
24
+
25
+ resolved_libs.each { |name| require name }
26
+ @libs_required = true
27
+ debug("required #{resolved_libs.size} workspace lib(s): #{resolved_libs.to_a.sort.join(', ')}")
17
28
  end
18
29
  end
@@ -24,6 +24,7 @@ module Rwm
24
24
  @graph = graph
25
25
  @cache_dir = File.join(workspace.root, ".rwm", "cache")
26
26
  @content_hashes = {}
27
+ @content_hash_mutex = Mutex.new
27
28
  @cache_declarations = {}
28
29
  @declarations_mutex = Mutex.new
29
30
  end
@@ -78,7 +79,9 @@ module Rwm
78
79
 
79
80
  # Compute a content hash for a package: SHA256 of all source files + dependency hashes
80
81
  def content_hash(package)
81
- return @content_hashes[package.name] if @content_hashes.key?(package.name)
82
+ @content_hash_mutex.synchronize do
83
+ return @content_hashes[package.name] if @content_hashes.key?(package.name)
84
+ end
82
85
 
83
86
  digest = Digest::SHA256.new
84
87
 
@@ -97,7 +100,10 @@ module Rwm
97
100
  digest.update(content_hash(dep_pkg))
98
101
  end
99
102
 
100
- @content_hashes[package.name] = digest.hexdigest
103
+ computed = digest.hexdigest
104
+ @content_hash_mutex.synchronize do
105
+ @content_hashes[package.name] = computed
106
+ end
101
107
  end
102
108
 
103
109
  # Preload cache declarations for multiple packages in parallel.
@@ -73,6 +73,9 @@ module Rwm
73
73
  running[pkg.name] = Thread.new do
74
74
  begin
75
75
  result = run_single(pkg, &command_proc)
76
+ rescue IOError
77
+ # Thread killed during I/O (Ctrl+C) — suppress noise
78
+ next
76
79
  rescue => e
77
80
  result = Result.new(
78
81
  package_name: pkg.name, task: "error",
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.6.1"
4
+ VERSION = "0.6.3"
5
5
  end
metadata CHANGED
@@ -1,14 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_workspace_manager
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.6.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Siddharth Bhatt
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: tsort
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
12
26
  description: Convention-over-configuration monorepo tool for Ruby. Manages dependency
13
27
  graphs, runs tasks in parallel, detects affected packages, and enforces structural
14
28
  conventions.
@@ -22,6 +36,7 @@ files:
22
36
  - bin/rwm
23
37
  - completions/rwm.bash
24
38
  - completions/rwm.zsh
39
+ - lib/ruby_workspace_manager.rb
25
40
  - lib/rwm.rb
26
41
  - lib/rwm/affected_detector.rb
27
42
  - lib/rwm/cli.rb