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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +32 -0
- data/bin/rwm +6 -0
- data/lib/rwm/affected_detector.rb +102 -0
- data/lib/rwm/cli.rb +98 -0
- data/lib/rwm/commands/affected.rb +52 -0
- data/lib/rwm/commands/bootstrap.rb +147 -0
- data/lib/rwm/commands/check.rb +27 -0
- data/lib/rwm/commands/graph.rb +46 -0
- data/lib/rwm/commands/info.rb +41 -0
- data/lib/rwm/commands/init.rb +113 -0
- data/lib/rwm/commands/list.rb +45 -0
- data/lib/rwm/commands/new.rb +150 -0
- data/lib/rwm/commands/run.rb +132 -0
- data/lib/rwm/convention_checker.rb +70 -0
- data/lib/rwm/dependency_graph.rb +226 -0
- data/lib/rwm/errors.rb +43 -0
- data/lib/rwm/gemfile.rb +32 -0
- data/lib/rwm/gemfile_parser.rb +48 -0
- data/lib/rwm/git_hooks.rb +55 -0
- data/lib/rwm/overcommit.rb +99 -0
- data/lib/rwm/package.rb +62 -0
- data/lib/rwm/rake.rb +51 -0
- data/lib/rwm/task_cache.rb +114 -0
- data/lib/rwm/task_runner.rb +148 -0
- data/lib/rwm/version.rb +5 -0
- data/lib/rwm/vscode_workspace.rb +57 -0
- data/lib/rwm/workspace.rb +73 -0
- data/lib/rwm.rb +19 -0
- metadata +72 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
|
|
8
|
+
module Rwm
|
|
9
|
+
class TaskCache
|
|
10
|
+
def initialize(workspace, graph)
|
|
11
|
+
@workspace = workspace
|
|
12
|
+
@graph = graph
|
|
13
|
+
@cache_dir = File.join(workspace.root, ".rwm", "cache")
|
|
14
|
+
@content_hashes = {}
|
|
15
|
+
@cache_declarations = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns true if the task is declared cacheable in the package's Rakefile
|
|
19
|
+
def cacheable?(package, task)
|
|
20
|
+
declarations = cache_declarations(package)
|
|
21
|
+
declarations.key?(task)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns true if the (package, task) pair is cached and inputs haven't changed.
|
|
25
|
+
# Also verifies declared outputs exist (if any).
|
|
26
|
+
def cached?(package, task)
|
|
27
|
+
stored = read_stored_hash(package, task)
|
|
28
|
+
return false unless stored
|
|
29
|
+
return false unless stored == content_hash(package)
|
|
30
|
+
|
|
31
|
+
# If outputs are declared, they must exist
|
|
32
|
+
decl = cache_declarations(package)[task]
|
|
33
|
+
if decl && decl["output"]
|
|
34
|
+
return false unless outputs_exist?(package, decl["output"])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Store the current content hash after a successful task run
|
|
41
|
+
def store(package, task)
|
|
42
|
+
FileUtils.mkdir_p(@cache_dir)
|
|
43
|
+
path = cache_file(package, task)
|
|
44
|
+
File.write(path, content_hash(package))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Check if declared output files/globs exist in the package directory
|
|
48
|
+
def outputs_exist?(package, output_pattern)
|
|
49
|
+
matches = Dir.glob(File.join(package.path, output_pattern))
|
|
50
|
+
!matches.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Compute a content hash for a package: SHA256 of all source files + dependency hashes
|
|
54
|
+
def content_hash(package)
|
|
55
|
+
return @content_hashes[package.name] if @content_hashes.key?(package.name)
|
|
56
|
+
|
|
57
|
+
digest = Digest::SHA256.new
|
|
58
|
+
|
|
59
|
+
# Hash all source files in the package (sorted for determinism)
|
|
60
|
+
source_files(package).each do |file|
|
|
61
|
+
rel_path = file.delete_prefix("#{package.path}/")
|
|
62
|
+
digest.update(rel_path)
|
|
63
|
+
digest.update(File.read(file))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Include dependency content hashes (transitive invalidation)
|
|
67
|
+
@graph.dependencies(package.name).sort.each do |dep_name|
|
|
68
|
+
dep_pkg = @workspace.find_package(dep_name)
|
|
69
|
+
digest.update(content_hash(dep_pkg))
|
|
70
|
+
rescue PackageNotFoundError
|
|
71
|
+
next
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@content_hashes[package.name] = digest.hexdigest
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Discover cacheable task declarations by running `bundle exec rake rwm:cache_config`
|
|
78
|
+
def cache_declarations(package)
|
|
79
|
+
return @cache_declarations[package.name] if @cache_declarations.key?(package.name)
|
|
80
|
+
|
|
81
|
+
output = `cd #{Shellwords.escape(package.path)} && bundle exec rake rwm:cache_config 2>/dev/null`
|
|
82
|
+
@cache_declarations[package.name] = if $?.success? && !output.strip.empty?
|
|
83
|
+
JSON.parse(output.strip)
|
|
84
|
+
else
|
|
85
|
+
{}
|
|
86
|
+
end
|
|
87
|
+
rescue JSON::ParserError
|
|
88
|
+
@cache_declarations[package.name] = {}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def source_files(package)
|
|
94
|
+
Dir.glob(File.join(package.path, "**", "*"))
|
|
95
|
+
.select { |f| File.file?(f) }
|
|
96
|
+
.reject do |f|
|
|
97
|
+
rel = f.delete_prefix("#{package.path}/")
|
|
98
|
+
rel.start_with?("tmp/") || rel.start_with?("vendor/") || rel.start_with?(".bundle/")
|
|
99
|
+
end
|
|
100
|
+
.sort
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def cache_file(package, task)
|
|
104
|
+
File.join(@cache_dir, "#{package.name}-#{task}")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def read_stored_hash(package, task)
|
|
108
|
+
path = cache_file(package, task)
|
|
109
|
+
return nil unless File.exist?(path)
|
|
110
|
+
|
|
111
|
+
File.read(path).strip
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "etc"
|
|
5
|
+
|
|
6
|
+
module Rwm
|
|
7
|
+
class TaskRunner
|
|
8
|
+
Result = Struct.new(:package_name, :task, :success, :output, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
attr_reader :results
|
|
11
|
+
|
|
12
|
+
def initialize(graph, packages: nil, buffered: false, concurrency: Etc.nprocessors)
|
|
13
|
+
@graph = graph
|
|
14
|
+
@packages = packages || graph.packages.values
|
|
15
|
+
@buffered = buffered
|
|
16
|
+
@concurrency = concurrency
|
|
17
|
+
@results = []
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Run a shell command in each package using DAG scheduling.
|
|
22
|
+
# Starts each package as soon as its dependencies complete.
|
|
23
|
+
# The command_proc receives a Package and returns [command, args] array.
|
|
24
|
+
def run_command(&command_proc)
|
|
25
|
+
package_names = @packages.map(&:name).to_set
|
|
26
|
+
|
|
27
|
+
pending = @packages.dup
|
|
28
|
+
completed = Set.new
|
|
29
|
+
skipped = Set.new
|
|
30
|
+
running = {}
|
|
31
|
+
|
|
32
|
+
mutex = Mutex.new
|
|
33
|
+
condition = ConditionVariable.new
|
|
34
|
+
|
|
35
|
+
until pending.empty? && running.empty?
|
|
36
|
+
mutex.synchronize do
|
|
37
|
+
ready = pending.select { |pkg| ready?(pkg, package_names, completed) }
|
|
38
|
+
|
|
39
|
+
ready.each do |pkg|
|
|
40
|
+
break if running.size >= @concurrency
|
|
41
|
+
|
|
42
|
+
pending.delete(pkg)
|
|
43
|
+
running[pkg.name] = Thread.new do
|
|
44
|
+
result = run_single(pkg, &command_proc)
|
|
45
|
+
mutex.synchronize do
|
|
46
|
+
@results << result
|
|
47
|
+
running.delete(pkg.name)
|
|
48
|
+
if result.success
|
|
49
|
+
completed << pkg.name
|
|
50
|
+
else
|
|
51
|
+
skip_names = @graph.transitive_dependents(pkg.name)
|
|
52
|
+
.select { |n| package_names.include?(n) }
|
|
53
|
+
skip_names.each do |name|
|
|
54
|
+
skip_pkg = pending.find { |p| p.name == name }
|
|
55
|
+
if skip_pkg
|
|
56
|
+
pending.delete(skip_pkg)
|
|
57
|
+
skipped << name
|
|
58
|
+
@results << Result.new(
|
|
59
|
+
package_name: name, task: "skipped",
|
|
60
|
+
success: false, output: "Skipped due to failed dependency: #{pkg.name}"
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
condition.broadcast
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if running.any? && ready.empty?
|
|
71
|
+
condition.wait(mutex)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@results
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Run a rake task in each package
|
|
80
|
+
def run_task(task)
|
|
81
|
+
run_command do |pkg|
|
|
82
|
+
["bundle", "exec", "rake", task]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def success?
|
|
87
|
+
@results.all?(&:success)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def failed_results
|
|
91
|
+
@results.select { |r| !r.success }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def ready?(pkg, run_set, completed)
|
|
97
|
+
@graph.dependencies(pkg.name).each do |dep|
|
|
98
|
+
next unless run_set.include?(dep)
|
|
99
|
+
|
|
100
|
+
return false unless completed.include?(dep)
|
|
101
|
+
end
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def run_single(pkg, &command_proc)
|
|
106
|
+
cmd = command_proc.call(pkg)
|
|
107
|
+
prefix = "[#{pkg.name}]"
|
|
108
|
+
|
|
109
|
+
stdout, stderr, status = Open3.capture3(*cmd, chdir: pkg.path)
|
|
110
|
+
output = format_output(prefix, stdout, stderr)
|
|
111
|
+
|
|
112
|
+
if @buffered
|
|
113
|
+
print_buffered_output(pkg.name, output, status.success?)
|
|
114
|
+
else
|
|
115
|
+
print_output(output)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
Result.new(
|
|
119
|
+
package_name: pkg.name,
|
|
120
|
+
task: cmd.join(" "),
|
|
121
|
+
success: status.success?,
|
|
122
|
+
output: output
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def format_output(prefix, stdout, stderr)
|
|
127
|
+
lines = []
|
|
128
|
+
stdout.each_line { |line| lines << "#{prefix} #{line}" }
|
|
129
|
+
stderr.each_line { |line| lines << "#{prefix} #{line}" }
|
|
130
|
+
lines.join
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def print_output(output)
|
|
134
|
+
@mutex.synchronize do
|
|
135
|
+
$stdout.print(output) unless output.empty?
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def print_buffered_output(name, output, success)
|
|
140
|
+
@mutex.synchronize do
|
|
141
|
+
stream = success ? $stdout : $stderr
|
|
142
|
+
stream.puts "==> [#{name}]"
|
|
143
|
+
stream.print(output) unless output.empty?
|
|
144
|
+
stream.puts
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/rwm/version.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rwm
|
|
6
|
+
class VscodeWorkspace
|
|
7
|
+
PRESERVE_KEYS = %w[settings extensions launch tasks].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :root
|
|
10
|
+
|
|
11
|
+
def initialize(root)
|
|
12
|
+
@root = root
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def file_path
|
|
16
|
+
File.join(root, "#{File.basename(root)}.code-workspace")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def generate(packages)
|
|
20
|
+
existing = load_existing
|
|
21
|
+
folders = build_folders(packages)
|
|
22
|
+
|
|
23
|
+
data = { "folders" => folders }
|
|
24
|
+
|
|
25
|
+
PRESERVE_KEYS.each do |key|
|
|
26
|
+
data[key] = existing[key] if existing.key?(key)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
data["settings"] ||= {}
|
|
30
|
+
|
|
31
|
+
File.write(file_path, JSON.pretty_generate(data) + "\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def build_folders(packages)
|
|
37
|
+
folders = [{ "path" => "." }]
|
|
38
|
+
|
|
39
|
+
libs = packages.select(&:lib?).sort_by(&:name)
|
|
40
|
+
apps = packages.select(&:app?).sort_by(&:name)
|
|
41
|
+
|
|
42
|
+
(libs + apps).each do |pkg|
|
|
43
|
+
folders << { "path" => pkg.relative_path(root) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
folders
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def load_existing
|
|
50
|
+
return {} unless File.exist?(file_path)
|
|
51
|
+
|
|
52
|
+
JSON.parse(File.read(file_path))
|
|
53
|
+
rescue JSON::ParserError
|
|
54
|
+
{}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rwm
|
|
4
|
+
class Workspace
|
|
5
|
+
RWM_DIR = ".rwm"
|
|
6
|
+
PACKAGE_DIRS = %w[libs apps].freeze
|
|
7
|
+
GRAPH_FILE = "graph.json"
|
|
8
|
+
|
|
9
|
+
attr_reader :root
|
|
10
|
+
|
|
11
|
+
def initialize(root)
|
|
12
|
+
@root = root
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Find the workspace root via git
|
|
16
|
+
def self.find(start_dir = Dir.pwd)
|
|
17
|
+
dir = File.expand_path(start_dir)
|
|
18
|
+
git_root = `git -C #{dir} rev-parse --show-toplevel 2>/dev/null`.chomp
|
|
19
|
+
|
|
20
|
+
raise WorkspaceNotFoundError if git_root.empty?
|
|
21
|
+
|
|
22
|
+
new(git_root)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def rwm_dir
|
|
26
|
+
File.join(root, RWM_DIR)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def graph_path
|
|
30
|
+
File.join(rwm_dir, GRAPH_FILE)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def libs_dir
|
|
34
|
+
File.join(root, "libs")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def apps_dir
|
|
38
|
+
File.join(root, "apps")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Discover all packages by scanning libs/ and apps/ for directories with a Gemfile
|
|
42
|
+
def packages
|
|
43
|
+
@packages ||= discover_packages
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def find_package(name)
|
|
47
|
+
packages.find { |p| p.name == name } || raise(PackageNotFoundError, name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def discover_packages
|
|
53
|
+
pkgs = []
|
|
54
|
+
|
|
55
|
+
PACKAGE_DIRS.each do |dir_name|
|
|
56
|
+
base = File.join(root, dir_name)
|
|
57
|
+
next unless File.directory?(base)
|
|
58
|
+
|
|
59
|
+
type = dir_name == "libs" ? :lib : :app
|
|
60
|
+
|
|
61
|
+
Dir.children(base).sort.each do |name|
|
|
62
|
+
pkg_path = File.join(base, name)
|
|
63
|
+
next unless File.directory?(pkg_path)
|
|
64
|
+
next unless File.exist?(File.join(pkg_path, "Gemfile"))
|
|
65
|
+
|
|
66
|
+
pkgs << Package.new(name: name, path: pkg_path, type: type)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
pkgs
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/rwm.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rwm/version"
|
|
4
|
+
require_relative "rwm/errors"
|
|
5
|
+
|
|
6
|
+
module Rwm
|
|
7
|
+
autoload :Workspace, "rwm/workspace"
|
|
8
|
+
autoload :Package, "rwm/package"
|
|
9
|
+
autoload :GemfileParser, "rwm/gemfile_parser"
|
|
10
|
+
autoload :DependencyGraph, "rwm/dependency_graph"
|
|
11
|
+
autoload :ConventionChecker, "rwm/convention_checker"
|
|
12
|
+
autoload :TaskRunner, "rwm/task_runner"
|
|
13
|
+
autoload :AffectedDetector, "rwm/affected_detector"
|
|
14
|
+
autoload :TaskCache, "rwm/task_cache"
|
|
15
|
+
autoload :GitHooks, "rwm/git_hooks"
|
|
16
|
+
autoload :Overcommit, "rwm/overcommit"
|
|
17
|
+
autoload :VscodeWorkspace, "rwm/vscode_workspace"
|
|
18
|
+
autoload :CLI, "rwm/cli"
|
|
19
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby_workspace_manager
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Siddharth Bhatt
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Convention-over-configuration monorepo tool for Ruby. Manages dependency
|
|
13
|
+
graphs, runs tasks in parallel, detects affected packages, and enforces structural
|
|
14
|
+
conventions.
|
|
15
|
+
executables:
|
|
16
|
+
- rwm
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- LICENSE.txt
|
|
21
|
+
- README.md
|
|
22
|
+
- bin/rwm
|
|
23
|
+
- lib/rwm.rb
|
|
24
|
+
- lib/rwm/affected_detector.rb
|
|
25
|
+
- lib/rwm/cli.rb
|
|
26
|
+
- lib/rwm/commands/affected.rb
|
|
27
|
+
- lib/rwm/commands/bootstrap.rb
|
|
28
|
+
- lib/rwm/commands/check.rb
|
|
29
|
+
- lib/rwm/commands/graph.rb
|
|
30
|
+
- lib/rwm/commands/info.rb
|
|
31
|
+
- lib/rwm/commands/init.rb
|
|
32
|
+
- lib/rwm/commands/list.rb
|
|
33
|
+
- lib/rwm/commands/new.rb
|
|
34
|
+
- lib/rwm/commands/run.rb
|
|
35
|
+
- lib/rwm/convention_checker.rb
|
|
36
|
+
- lib/rwm/dependency_graph.rb
|
|
37
|
+
- lib/rwm/errors.rb
|
|
38
|
+
- lib/rwm/gemfile.rb
|
|
39
|
+
- lib/rwm/gemfile_parser.rb
|
|
40
|
+
- lib/rwm/git_hooks.rb
|
|
41
|
+
- lib/rwm/overcommit.rb
|
|
42
|
+
- lib/rwm/package.rb
|
|
43
|
+
- lib/rwm/rake.rb
|
|
44
|
+
- lib/rwm/task_cache.rb
|
|
45
|
+
- lib/rwm/task_runner.rb
|
|
46
|
+
- lib/rwm/version.rb
|
|
47
|
+
- lib/rwm/vscode_workspace.rb
|
|
48
|
+
- lib/rwm/workspace.rb
|
|
49
|
+
homepage: https://github.com/sidbhatt11/ruby-workspace-manager
|
|
50
|
+
licenses:
|
|
51
|
+
- MIT
|
|
52
|
+
metadata:
|
|
53
|
+
homepage_uri: https://github.com/sidbhatt11/ruby-workspace-manager
|
|
54
|
+
source_code_uri: https://github.com/sidbhatt11/ruby-workspace-manager
|
|
55
|
+
rdoc_options: []
|
|
56
|
+
require_paths:
|
|
57
|
+
- lib
|
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: 3.4.0
|
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
requirements: []
|
|
69
|
+
rubygems_version: 4.0.6
|
|
70
|
+
specification_version: 4
|
|
71
|
+
summary: Ruby Workspace Manager — an Nx-like monorepo tool for Ruby
|
|
72
|
+
test_files: []
|