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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwm
4
+ VERSION = "0.2.0"
5
+ end
@@ -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: []