ruby_workspace_manager 0.2.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6e8c287aa2d28cf7ed4c21f9ce567eb25f5a2164e0d064619aa2110676461ae0
4
+ data.tar.gz: b2d3ca1d17eecdf4b9233469bdaf24c4f43a9d7ec3cb6bd99fdb9fb2a527b419
5
+ SHA512:
6
+ metadata.gz: 9b1c5e6dac68401d8ec61e70323af0bea421370354a5bfa36d66917bcd08222026b9ac535e800c1aa5f8405ffde6759381c53fecea629972f799e42e1176f4b2
7
+ data.tar.gz: a535d8642e4c5c3736a4606954e4a21a2d32e8926293d89ca2622fc3c8c0ced4b3667e0d5375b51c5b23cece660d76b1aa9e062cf270d73c001977b2e4a5ab7d
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Siddharth Bhatt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,32 @@
1
+ [![CI](https://github.com/sidbhatt11/ruby-workspace-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/sidbhatt11/ruby-workspace-manager/actions/workflows/ci.yml)
2
+ ![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%203.4-red)
3
+
4
+ # RWM — Ruby Workspace Manager
5
+
6
+ An Nx-like monorepo tool for Ruby. Convention-over-configuration, zero runtime dependencies, delegates to Rake.
7
+
8
+ ## What it does
9
+
10
+ RWM manages Ruby monorepos with multiple apps and libraries. It builds a dependency graph from your Gemfiles, enforces structural conventions, runs tasks in parallel respecting dependency order, detects affected packages from git changes, and caches results so unchanged work is never repeated.
11
+
12
+ ## Commands
13
+
14
+ | Command | Description |
15
+ |---------|-------------|
16
+ | `rwm init` | Initialize a workspace. |
17
+ | `rwm bootstrap` | Install deps, build graph, install hooks, run bootstrap tasks. |
18
+ | `rwm new <app\|lib> <name>` | Scaffold a new package. |
19
+ | `rwm graph` | Rebuild the dependency graph. `--dot` / `--mermaid` for visualization. |
20
+ | `rwm run <task> [pkg]` | Run a Rake task across packages. Packages without the task are skipped. |
21
+ | `rwm test` | Shortcut for `rwm run test`. Also: `rwm spec`, `rwm build`. |
22
+ | `rwm run <task> --affected` | Run only on packages affected by current changes. |
23
+ | `rwm check` | Validate conventions. |
24
+ | `rwm list` | List all packages. |
25
+ | `rwm info <name>` | Show package details. |
26
+ | `rwm affected` | Show affected packages. |
27
+
28
+ See [GUIDE.md](GUIDE.md) for full usage documentation — dependencies, caching, affected detection, git hooks, design decisions, and more.
29
+
30
+ ## License
31
+
32
+ MIT
data/bin/rwm ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rwm"
5
+
6
+ Rwm::CLI.run(ARGV)
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwm
4
+ class AffectedDetector
5
+ attr_reader :workspace, :graph, :base_branch
6
+
7
+ def initialize(workspace, graph, committed_only: false)
8
+ @workspace = workspace
9
+ @graph = graph
10
+ @committed_only = committed_only
11
+ @base_branch = detect_base_branch
12
+ end
13
+
14
+ # Returns packages directly changed + their transitive dependents
15
+ def affected_packages
16
+ changed_files = detect_changed_files
17
+ directly_changed = map_files_to_packages(changed_files)
18
+
19
+ # If root-level files changed (outside any package), all packages are affected
20
+ root_files = changed_files.reject { |f| file_in_any_package?(f) }
21
+ unless root_files.empty?
22
+ return workspace.packages
23
+ end
24
+
25
+ # Collect transitive dependents of directly changed packages
26
+ all_affected = Set.new(directly_changed.map(&:name))
27
+ directly_changed.each do |pkg|
28
+ graph.transitive_dependents(pkg.name).each { |name| all_affected << name }
29
+ end
30
+
31
+ workspace.packages.select { |pkg| all_affected.include?(pkg.name) }
32
+ end
33
+
34
+ # Just the directly changed packages (no dependents)
35
+ def directly_changed_packages
36
+ changed_files = detect_changed_files
37
+ map_files_to_packages(changed_files)
38
+ end
39
+
40
+ private
41
+
42
+ def detect_base_branch
43
+ # Try to read the remote's default branch
44
+ ref = `git -C #{workspace.root} symbolic-ref refs/remotes/origin/HEAD 2>/dev/null`.chomp
45
+ unless ref.empty?
46
+ # refs/remotes/origin/main → main
47
+ return ref.sub(%r{^refs/remotes/origin/}, "")
48
+ end
49
+
50
+ # Fall back: check if main or master exists
51
+ branches = `git -C #{workspace.root} branch --list main master 2>/dev/null`.lines.map(&:strip)
52
+ return "main" if branches.include?("main")
53
+ return "master" if branches.include?("master")
54
+
55
+ # Last resort
56
+ "main"
57
+ end
58
+
59
+ def detect_changed_files
60
+ files = Set.new
61
+
62
+ # 1. Committed changes: base branch vs HEAD
63
+ committed = `git -C #{workspace.root} diff --name-only #{base_branch}...HEAD 2>/dev/null`.chomp
64
+ committed.lines.each { |l| files << l.chomp }
65
+
66
+ unless @committed_only
67
+ # 2. Staged changes (not yet committed)
68
+ staged = `git -C #{workspace.root} diff --name-only --cached 2>/dev/null`.chomp
69
+ staged.lines.each { |l| files << l.chomp }
70
+
71
+ # 3. Unstaged working directory changes
72
+ unstaged = `git -C #{workspace.root} diff --name-only 2>/dev/null`.chomp
73
+ unstaged.lines.each { |l| files << l.chomp }
74
+ end
75
+
76
+ files.reject(&:empty?).to_a
77
+ end
78
+
79
+ def map_files_to_packages(files)
80
+ packages = workspace.packages
81
+ matched = Set.new
82
+
83
+ files.each do |file|
84
+ packages.each do |pkg|
85
+ rel_path = pkg.relative_path(workspace.root)
86
+ if file.start_with?("#{rel_path}/")
87
+ matched << pkg
88
+ end
89
+ end
90
+ end
91
+
92
+ matched.to_a
93
+ end
94
+
95
+ def file_in_any_package?(file)
96
+ workspace.packages.any? do |pkg|
97
+ rel_path = pkg.relative_path(workspace.root)
98
+ file.start_with?("#{rel_path}/")
99
+ end
100
+ end
101
+ end
102
+ end
data/lib/rwm/cli.rb ADDED
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Rwm
6
+ class CLI
7
+ COMMANDS = {
8
+ "init" => "Commands::Init",
9
+ "bootstrap" => "Commands::Bootstrap",
10
+ "new" => "Commands::New",
11
+ "info" => "Commands::Info",
12
+ "graph" => "Commands::Graph",
13
+ "check" => "Commands::Check",
14
+ "list" => "Commands::List",
15
+ "run" => "Commands::Run",
16
+ "affected" => "Commands::Affected"
17
+ }.freeze
18
+
19
+ # Shortcuts that expand to `run <task>`
20
+ TASK_SHORTCUTS = %w[test spec build].freeze
21
+
22
+ def self.run(argv)
23
+ new(argv).run
24
+ end
25
+
26
+ def initialize(argv)
27
+ @argv = argv.dup
28
+ end
29
+
30
+ def run
31
+ command_name = @argv.shift
32
+
33
+ if command_name.nil? || %w[-h --help help].include?(command_name)
34
+ print_help
35
+ return 0
36
+ end
37
+
38
+ if %w[-v --version version].include?(command_name)
39
+ puts "rwm #{Rwm::VERSION}"
40
+ return 0
41
+ end
42
+
43
+ # Expand task shortcuts: `rwm test` → `rwm run test`
44
+ if TASK_SHORTCUTS.include?(command_name)
45
+ @argv.unshift(command_name)
46
+ command_name = "run"
47
+ end
48
+
49
+ const_name = COMMANDS[command_name]
50
+ unless const_name
51
+ $stderr.puts "Unknown command: #{command_name}"
52
+ $stderr.puts "Run `rwm help` for available commands."
53
+ return 1
54
+ end
55
+
56
+ # Autoload the command
57
+ require "rwm/commands/#{command_name}"
58
+ command_class = const_name.split("::").reduce(Rwm) { |mod, name| mod.const_get(name) }
59
+ command_class.new(@argv).run
60
+ rescue Rwm::Error => e
61
+ $stderr.puts "Error: #{e.message}"
62
+ 1
63
+ end
64
+
65
+ private
66
+
67
+ def print_help
68
+ puts <<~HELP
69
+ rwm #{Rwm::VERSION} — Ruby Workspace Manager
70
+
71
+ Usage: rwm <command> [options]
72
+
73
+ Commands:
74
+ init Initialize a new rwm workspace
75
+ bootstrap Install deps and run bootstrap tasks in all packages
76
+ new <type> <name> Scaffold a new app or lib
77
+ info <name> Show details about a package
78
+ graph Build and save the dependency graph
79
+ --dot Output in Graphviz DOT format
80
+ --mermaid Output in Mermaid format
81
+ check Validate dependency graph and conventions
82
+ run <task> [pkg] Run a rake task across all (or one) package(s)
83
+ test Shortcut for `rwm run test`
84
+ affected Show packages affected by current changes
85
+ list List all packages in the workspace
86
+ help Show this help
87
+
88
+ Run options:
89
+ --affected Only run on affected packages
90
+ --no-cache Bypass task-level caching
91
+
92
+ Options:
93
+ -h, --help Show this help
94
+ -v, --version Show version
95
+ HELP
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Rwm
6
+ module Commands
7
+ class Affected
8
+ def initialize(argv)
9
+ @argv = argv
10
+ @committed_only = false
11
+ parse_options
12
+ end
13
+
14
+ def run
15
+ workspace = Workspace.find
16
+ graph = DependencyGraph.load(workspace)
17
+ detector = AffectedDetector.new(workspace, graph, committed_only: @committed_only)
18
+
19
+ affected = detector.affected_packages
20
+ directly_changed = detector.directly_changed_packages
21
+
22
+ if affected.empty?
23
+ puts "No packages affected."
24
+ return 0
25
+ end
26
+
27
+ puts "Base branch: #{detector.base_branch}"
28
+ puts "Affected packages (#{affected.size}):"
29
+ puts
30
+
31
+ affected.each do |pkg|
32
+ marker = directly_changed.include?(pkg) ? "(changed)" : "(dependent)"
33
+ puts " #{pkg.name} #{marker}"
34
+ end
35
+
36
+ 0
37
+ end
38
+
39
+ private
40
+
41
+ def parse_options
42
+ parser = OptionParser.new do |opts|
43
+ opts.on("--committed", "Only consider committed changes (ignore staged/unstaged)") do
44
+ @committed_only = true
45
+ end
46
+ end
47
+
48
+ parser.order!(@argv)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwm
4
+ module Commands
5
+ class Bootstrap
6
+ def initialize(argv)
7
+ @argv = argv
8
+ end
9
+
10
+ def run
11
+ workspace = Workspace.find
12
+
13
+ bootstrap_root(workspace)
14
+ setup_hooks(workspace)
15
+
16
+ graph = DependencyGraph.build(workspace)
17
+ bootstrap_packages(workspace, graph)
18
+ save_graph(workspace, graph)
19
+ validate_conventions(workspace, graph)
20
+ update_vscode_workspace(workspace)
21
+
22
+ puts
23
+ puts "Bootstrap complete!"
24
+ 0
25
+ end
26
+
27
+ private
28
+
29
+ def bootstrap_root(workspace)
30
+ puts "==> Bootstrapping workspace root..."
31
+ run_bundle_install(workspace.root)
32
+ run_rake_bootstrap(workspace.root)
33
+ end
34
+
35
+ def setup_hooks(workspace)
36
+ if File.exist?(File.join(workspace.root, ".overcommit.yml"))
37
+ puts "==> Setting up overcommit..."
38
+
39
+ overcommit = Overcommit.new(workspace.root)
40
+ if overcommit.setup
41
+ puts " Overcommit configured."
42
+ else
43
+ $stderr.puts " Warning: Could not install overcommit hooks."
44
+ $stderr.puts " Run `overcommit --install` manually after installing the overcommit gem."
45
+ puts " Hook scripts and config created (will activate once overcommit is installed)."
46
+ end
47
+ else
48
+ puts "==> Installing git hooks..."
49
+ hooks = GitHooks.new(workspace.root)
50
+ if hooks.setup
51
+ puts " Git hooks installed."
52
+ else
53
+ $stderr.puts " Warning: Could not install git hooks (no .git directory found)."
54
+ end
55
+ end
56
+ end
57
+
58
+ def bootstrap_packages(workspace, graph)
59
+ packages = workspace.packages
60
+ if packages.empty?
61
+ puts "==> No packages found. Skipping package bootstrap."
62
+ return
63
+ end
64
+
65
+ # Step 1: bundle install in all packages (parallel by execution level)
66
+ puts "==> Installing gems in #{packages.size} package(s)..."
67
+ install_runner = TaskRunner.new(graph, packages: packages)
68
+ install_runner.run_command do |pkg|
69
+ ["bundle", "install"]
70
+ end
71
+
72
+ unless install_runner.success?
73
+ failed = install_runner.failed_results
74
+ raise BootstrapError, "bundle install failed in: #{failed.map(&:package_name).join(", ")}"
75
+ end
76
+
77
+ # Step 2: rake bootstrap in all packages (parallel by execution level)
78
+ bootstrappable = packages.select(&:has_rakefile?)
79
+ unless bootstrappable.empty?
80
+ puts
81
+ puts "==> Running bootstrap tasks in #{bootstrappable.size} package(s)..."
82
+ bootstrap_runner = TaskRunner.new(graph, packages: bootstrappable)
83
+ bootstrap_runner.run_command do |pkg|
84
+ ["bundle", "exec", "rake", "bootstrap"]
85
+ end
86
+
87
+ unless bootstrap_runner.success?
88
+ failed = bootstrap_runner.failed_results
89
+ raise BootstrapError, "rake bootstrap failed in: #{failed.map(&:package_name).join(", ")}"
90
+ end
91
+ end
92
+ end
93
+
94
+ def save_graph(workspace, graph)
95
+ puts
96
+ puts "==> Saving dependency graph..."
97
+ graph.save(workspace.graph_path, workspace.root)
98
+ puts " Graph saved to .rwm/graph.json (#{graph.packages.size} packages, #{graph.edges.values.flatten.size} edges)"
99
+ end
100
+
101
+ def validate_conventions(_workspace, graph)
102
+ puts "==> Validating conventions..."
103
+ checker = ConventionChecker.new(graph)
104
+ violations = checker.check
105
+
106
+ if violations.empty?
107
+ puts " All conventions passed."
108
+ else
109
+ $stderr.puts "Convention violations found:"
110
+ violations.each { |v| $stderr.puts " - #{v}" }
111
+ end
112
+ end
113
+
114
+ def update_vscode_workspace(workspace)
115
+ vscode = VscodeWorkspace.new(workspace.root)
116
+ return unless File.exist?(vscode.file_path)
117
+
118
+ vscode.generate(workspace.packages)
119
+ end
120
+
121
+ def run_bundle_install(dir)
122
+ return unless File.exist?(File.join(dir, "Gemfile"))
123
+
124
+ puts " bundle install..."
125
+ success = system("bundle", "install", chdir: dir)
126
+ unless success
127
+ raise BootstrapError, "bundle install failed in #{dir}"
128
+ end
129
+ end
130
+
131
+ def run_rake_bootstrap(dir)
132
+ return unless File.exist?(File.join(dir, "Rakefile"))
133
+
134
+ # Check if bootstrap task exists before running it
135
+ has_task = system("bundle", "exec", "rake", "-s", "-T", "bootstrap",
136
+ chdir: dir, out: File::NULL, err: File::NULL)
137
+ return unless has_task
138
+
139
+ puts " rake bootstrap..."
140
+ success = system("bundle", "exec", "rake", "bootstrap", chdir: dir)
141
+ unless success
142
+ raise BootstrapError, "rake bootstrap failed in #{dir}"
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwm
4
+ module Commands
5
+ class Check
6
+ def initialize(argv)
7
+ @argv = argv
8
+ end
9
+
10
+ def run
11
+ workspace = Workspace.find
12
+ graph = DependencyGraph.load(workspace)
13
+ checker = ConventionChecker.new(graph)
14
+ violations = checker.check
15
+
16
+ if violations.empty?
17
+ puts " All conventions passed."
18
+ 0
19
+ else
20
+ $stderr.puts "Convention violations found:"
21
+ violations.each { |v| $stderr.puts " - #{v}" }
22
+ 1
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Rwm
6
+ module Commands
7
+ class Graph
8
+ def initialize(argv)
9
+ @argv = argv
10
+ @format = nil
11
+ parse_options
12
+ end
13
+
14
+ def run
15
+ workspace = Workspace.find
16
+ graph = DependencyGraph.build(workspace)
17
+ graph.save(workspace.graph_path, workspace.root)
18
+
19
+ puts " Graph saved to .rwm/graph.json (#{graph.packages.size} packages, #{graph.edges.values.flatten.size} edges)"
20
+
21
+ case @format
22
+ when :dot
23
+ puts graph.to_dot(workspace.root)
24
+ when :mermaid
25
+ puts graph.to_mermaid(workspace.root)
26
+ end
27
+
28
+ 0
29
+ end
30
+
31
+ private
32
+
33
+ def parse_options
34
+ OptionParser.new do |opts|
35
+ opts.on("--dot", "Output graph in Graphviz DOT format") do
36
+ @format = :dot
37
+ end
38
+
39
+ opts.on("--mermaid", "Output graph in Mermaid format") do
40
+ @format = :mermaid
41
+ end
42
+ end.parse!(@argv)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwm
4
+ module Commands
5
+ class Info
6
+ def initialize(argv)
7
+ @argv = argv
8
+ end
9
+
10
+ def run
11
+ name = @argv.shift
12
+
13
+ unless name
14
+ $stderr.puts "Usage: rwm info <package_name>"
15
+ return 1
16
+ end
17
+
18
+ workspace = Workspace.find
19
+ pkg = workspace.find_package(name)
20
+ graph = DependencyGraph.load(workspace)
21
+
22
+ deps = graph.dependencies(name)
23
+ dependents = graph.direct_dependents(name)
24
+ transitive = graph.transitive_dependents(name)
25
+
26
+ puts "Package: #{pkg.name}"
27
+ puts "Type: #{pkg.type}"
28
+ puts "Path: #{pkg.relative_path(workspace.root)}"
29
+ puts "Has Rakefile: #{pkg.has_rakefile? ? "yes" : "no"}"
30
+ puts "Dependencies: #{deps.empty? ? "(none)" : deps.sort.join(", ")}"
31
+ puts "Dependents: #{dependents.empty? ? "(none)" : dependents.sort.join(", ")}"
32
+
33
+ if transitive.size > dependents.size
34
+ puts "Transitive: #{transitive.sort.join(", ")}"
35
+ end
36
+
37
+ 0
38
+ end
39
+ end
40
+ end
41
+ end