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,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tsort"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module Rwm
8
+ class DependencyGraph
9
+ include TSort
10
+
11
+ attr_reader :packages, :edges
12
+
13
+ def initialize
14
+ @packages = {} # name => Package
15
+ @edges = {} # name => [dep_name, ...]
16
+ @dependents = {} # name => [dependent_name, ...]
17
+ end
18
+
19
+ def add_package(package)
20
+ @packages[package.name] = package
21
+ @edges[package.name] ||= []
22
+ @dependents[package.name] ||= []
23
+ end
24
+
25
+ def add_edge(from_name, to_name)
26
+ @edges[from_name] ||= []
27
+ @edges[from_name] << to_name unless @edges[from_name].include?(to_name)
28
+ @dependents[to_name] ||= []
29
+ @dependents[to_name] << from_name unless @dependents[to_name].include?(from_name)
30
+ end
31
+
32
+ # Dependencies of a package (what it depends on)
33
+ def dependencies(name)
34
+ @edges[name] || []
35
+ end
36
+
37
+ # Direct dependents of a package (what depends on it)
38
+ def direct_dependents(name)
39
+ @dependents[name] || []
40
+ end
41
+
42
+ # Walk the graph to find all transitive dependents
43
+ def transitive_dependents(name)
44
+ visited = Set.new
45
+ queue = [name]
46
+
47
+ until queue.empty?
48
+ current = queue.shift
49
+ direct_dependents(current).each do |dep|
50
+ next if visited.include?(dep)
51
+
52
+ visited << dep
53
+ queue << dep
54
+ end
55
+ end
56
+
57
+ visited.to_a
58
+ end
59
+
60
+ # Topological sort (dependencies before dependents)
61
+ def topological_order
62
+ tsort
63
+ rescue TSort::Cyclic => e
64
+ raise CycleError, [e.message]
65
+ end
66
+
67
+ # Group packages into execution levels — packages at the same level
68
+ # have no interdependencies and can run in parallel
69
+ def execution_levels
70
+ return [] if @packages.empty?
71
+
72
+ remaining = @packages.keys.dup
73
+ levels = []
74
+
75
+ until remaining.empty?
76
+ # Find packages whose deps are all already placed in earlier levels
77
+ placed = levels.flatten
78
+ level = remaining.select do |name|
79
+ dependencies(name).all? { |dep| placed.include?(dep) }
80
+ end
81
+
82
+ raise CycleError, ["Unable to resolve execution levels — possible cycle"] if level.empty?
83
+
84
+ levels << level.sort
85
+ remaining -= level
86
+ end
87
+
88
+ levels
89
+ end
90
+
91
+ # Load graph from cached .rwm/graph.json, falling back to build.
92
+ # Auto-rebuilds when any package Gemfile is newer than the cache.
93
+ def self.load(workspace)
94
+ path = workspace.graph_path
95
+ unless File.exist?(path)
96
+ return build_and_save(workspace)
97
+ end
98
+
99
+ if stale?(path, workspace.packages)
100
+ return build_and_save(workspace)
101
+ end
102
+
103
+ data = JSON.parse(File.read(path))
104
+ graph = new
105
+
106
+ workspace.packages.each { |pkg| graph.add_package(pkg) }
107
+
108
+ data["edges"]&.each do |name, deps|
109
+ deps.each { |dep| graph.add_edge(name, dep) }
110
+ end
111
+
112
+ graph
113
+ end
114
+
115
+ def self.build_and_save(workspace)
116
+ graph = build(workspace)
117
+ graph.save(workspace.graph_path, workspace.root)
118
+ graph
119
+ end
120
+
121
+ def self.stale?(graph_path, packages)
122
+ graph_mtime = File.mtime(graph_path)
123
+ packages.any? { |pkg| File.mtime(pkg.gemfile_path) > graph_mtime }
124
+ end
125
+
126
+ private_class_method :stale?, :build_and_save
127
+
128
+ # Build graph from a workspace by parsing all Gemfiles
129
+ def self.build(workspace)
130
+ graph = new
131
+
132
+ workspace.packages.each { |pkg| graph.add_package(pkg) }
133
+
134
+ workspace.packages.each do |pkg|
135
+ deps = GemfileParser.parse(pkg.gemfile_path, workspace.packages)
136
+ deps.each { |dep| graph.add_edge(pkg.name, dep.name) }
137
+ end
138
+
139
+ graph
140
+ end
141
+
142
+ # Serialize to JSON for .rwm/graph.json
143
+ def to_json_data
144
+ {
145
+ "version" => 1,
146
+ "generated_at" => Time.now.iso8601,
147
+ "packages" => @packages.transform_values do |pkg|
148
+ { "name" => pkg.name, "type" => pkg.type.to_s, "path" => pkg.relative_path(@workspace_root || "") }
149
+ end,
150
+ "edges" => @edges.transform_values(&:sort)
151
+ }
152
+ end
153
+
154
+ def save(path, workspace_root)
155
+ @workspace_root = workspace_root
156
+ dir = File.dirname(path)
157
+ FileUtils.mkdir_p(dir)
158
+ File.write(path, JSON.pretty_generate(to_json_data) + "\n")
159
+ end
160
+
161
+ def self.load_from_file(path)
162
+ data = JSON.parse(File.read(path))
163
+ graph = new
164
+
165
+ # We can't reconstruct full Package objects from JSON alone,
166
+ # but we can load the structure for validation
167
+ data["edges"]&.each do |name, deps|
168
+ graph.instance_variable_get(:@edges)[name] = deps
169
+ deps.each do |dep|
170
+ graph.instance_variable_get(:@dependents)[dep] ||= []
171
+ graph.instance_variable_get(:@dependents)[dep] << name
172
+ end
173
+ end
174
+
175
+ graph
176
+ end
177
+
178
+ def to_dot(workspace_root)
179
+ lines = []
180
+ lines << "digraph rwm {"
181
+ lines << " rankdir=LR;"
182
+ lines << " node [shape=box];"
183
+
184
+ @packages.each_value do |pkg|
185
+ lines << " \"#{pkg.name}\" [label=\"#{pkg.name} (#{pkg.type})\"];"
186
+ end
187
+
188
+ @edges.each do |from, deps|
189
+ deps.each do |to|
190
+ lines << " \"#{from}\" -> \"#{to}\";"
191
+ end
192
+ end
193
+
194
+ lines << "}"
195
+ lines.join("\n") + "\n"
196
+ end
197
+
198
+ def to_mermaid(workspace_root)
199
+ lines = []
200
+ lines << "graph LR"
201
+
202
+ @packages.each_value do |pkg|
203
+ lines << " #{pkg.name}[\"#{pkg.name} (#{pkg.type})\"]"
204
+ end
205
+
206
+ @edges.each do |from, deps|
207
+ deps.each do |to|
208
+ lines << " #{from} --> #{to}"
209
+ end
210
+ end
211
+
212
+ lines.join("\n") + "\n"
213
+ end
214
+
215
+ private
216
+
217
+ # TSort interface
218
+ def tsort_each_node(&block)
219
+ @packages.each_key(&block)
220
+ end
221
+
222
+ def tsort_each_child(node, &block)
223
+ (@edges[node] || []).each(&block)
224
+ end
225
+ end
226
+ end
data/lib/rwm/errors.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwm
4
+ class Error < StandardError; end
5
+
6
+ class WorkspaceNotFoundError < Error
7
+ def initialize
8
+ super("Not a git repository. rwm uses the git root as the workspace root. Run `git init` first, then `rwm init`.")
9
+ end
10
+ end
11
+
12
+ class CycleError < Error
13
+ attr_reader :cycles
14
+
15
+ def initialize(cycles)
16
+ @cycles = cycles
17
+ super("Dependency cycle detected: #{cycles.map { |c| c.join(" → ") }.join(", ")}")
18
+ end
19
+ end
20
+
21
+ class ConventionError < Error
22
+ attr_reader :violations
23
+
24
+ def initialize(violations)
25
+ @violations = violations
26
+ super("Convention violations:\n#{violations.map { |v| " - #{v}" }.join("\n")}")
27
+ end
28
+ end
29
+
30
+ class PackageNotFoundError < Error
31
+ def initialize(name)
32
+ super("Package not found: #{name}")
33
+ end
34
+ end
35
+
36
+ class PackageExistsError < Error
37
+ def initialize(name)
38
+ super("Package already exists: #{name}")
39
+ end
40
+ end
41
+
42
+ class BootstrapError < Error; end
43
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler DSL extension for rwm workspaces.
4
+ #
5
+ # Usage in a package Gemfile:
6
+ #
7
+ # require "rwm/gemfile"
8
+ #
9
+ # source "https://rubygems.org"
10
+ # gemspec
11
+ #
12
+ # rwm_lib "auth"
13
+
14
+ require "bundler"
15
+
16
+ module Rwm
17
+ module GemfileDsl
18
+ def rwm_workspace_root
19
+ @rwm_workspace_root ||= `git rev-parse --show-toplevel 2>/dev/null`.strip.tap do |root|
20
+ raise "rwm: not inside a git repository" if root.empty?
21
+ end
22
+ end
23
+
24
+ def rwm_lib(name, **opts)
25
+ path = File.join(rwm_workspace_root, "libs", name.to_s)
26
+ gem(name.to_s, **opts, path: path)
27
+ end
28
+
29
+ end
30
+ end
31
+
32
+ Bundler::Dsl.prepend(Rwm::GemfileDsl)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+
5
+ module Rwm
6
+ class GemfileParser
7
+ # Parse a Gemfile and extract path dependencies that match known packages
8
+ def self.parse(gemfile_path, known_packages)
9
+ new(gemfile_path, known_packages).parse
10
+ end
11
+
12
+ def initialize(gemfile_path, known_packages)
13
+ @gemfile_path = gemfile_path
14
+ @known_packages = known_packages
15
+ @package_by_path = index_packages_by_path
16
+ end
17
+
18
+ def parse
19
+ dsl = Bundler::Dsl.new
20
+ dsl.eval_gemfile(@gemfile_path)
21
+ deps = dsl.dependencies
22
+
23
+ gemfile_dir = File.expand_path(File.dirname(@gemfile_path))
24
+
25
+ deps.each_with_object([]) do |dep, result|
26
+ source = dep.source
27
+ next unless source.is_a?(Bundler::Source::Path)
28
+
29
+ # Resolve the path relative to the Gemfile's directory
30
+ dep_path = File.expand_path(source.path.to_s, gemfile_dir)
31
+
32
+ # Skip self-references (gemspec directive points to own directory)
33
+ next if dep_path == gemfile_dir
34
+
35
+ matched = @package_by_path[dep_path]
36
+ result << matched if matched
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def index_packages_by_path
43
+ @known_packages.each_with_object({}) do |pkg, index|
44
+ index[pkg.path] = pkg
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Rwm
6
+ class GitHooks
7
+ PRE_PUSH_HOOK = <<~BASH
8
+ #!/bin/bash
9
+ bundle exec rwm check
10
+ BASH
11
+
12
+ POST_COMMIT_HOOK = <<~BASH
13
+ #!/bin/bash
14
+ # Only rebuild graph if a Gemfile changed in this commit
15
+ if git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null | grep -q 'Gemfile'; then
16
+ bundle exec rwm graph
17
+ fi
18
+ BASH
19
+
20
+ def initialize(workspace_root)
21
+ @root = workspace_root
22
+ end
23
+
24
+ def setup
25
+ hooks_dir = File.join(@root, ".git", "hooks")
26
+ return false unless File.directory?(File.join(@root, ".git"))
27
+
28
+ FileUtils.mkdir_p(hooks_dir)
29
+ install_hook(hooks_dir, "pre-push", PRE_PUSH_HOOK)
30
+ install_hook(hooks_dir, "post-commit", POST_COMMIT_HOOK)
31
+ true
32
+ end
33
+
34
+ private
35
+
36
+ def install_hook(hooks_dir, name, content)
37
+ path = File.join(hooks_dir, name)
38
+
39
+ if File.exist?(path)
40
+ existing = File.read(path)
41
+ return if existing.include?("bundle exec rwm")
42
+ # Append to existing hook
43
+ File.open(path, "a") do |f|
44
+ f.puts
45
+ f.puts "# rwm hooks"
46
+ f.puts content.lines.drop(1).join # skip shebang
47
+ end
48
+ else
49
+ File.write(path, content)
50
+ end
51
+
52
+ File.chmod(0o755, path)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Rwm
7
+ class Overcommit
8
+ RWM_HOOKS = {
9
+ "PrePush" => {
10
+ "CustomScript" => {
11
+ "enabled" => true
12
+ }
13
+ },
14
+ "PostCommit" => {
15
+ "CustomScript" => {
16
+ "enabled" => true
17
+ }
18
+ }
19
+ }.freeze
20
+
21
+ PRE_PUSH_SCRIPT = <<~BASH
22
+ #!/bin/bash
23
+ bundle exec rwm check
24
+ BASH
25
+
26
+ POST_COMMIT_SCRIPT = <<~BASH
27
+ #!/bin/bash
28
+ # Only rebuild graph if a Gemfile changed in this commit
29
+ if git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null | grep -q 'Gemfile'; then
30
+ bundle exec rwm graph
31
+ fi
32
+ BASH
33
+
34
+ def initialize(workspace_root)
35
+ @root = workspace_root
36
+ end
37
+
38
+ # Sets up overcommit: installs hooks, configures .overcommit.yml,
39
+ # creates hook scripts, and signs the config.
40
+ # Returns true if overcommit was installed successfully.
41
+ def setup
42
+ configure_hooks
43
+ create_hook_scripts
44
+ installed = install_hooks
45
+ sign_config if installed
46
+ installed
47
+ end
48
+
49
+ private
50
+
51
+ def install_hooks
52
+ system("bundle", "exec", "overcommit", "--install", chdir: @root,
53
+ out: File::NULL, err: File::NULL)
54
+ end
55
+
56
+ def sign_config
57
+ system("bundle", "exec", "overcommit", "--sign", chdir: @root,
58
+ out: File::NULL, err: File::NULL)
59
+ end
60
+
61
+ def configure_hooks
62
+ config_path = File.join(@root, ".overcommit.yml")
63
+
64
+ existing = if File.exist?(config_path)
65
+ YAML.safe_load(File.read(config_path)) || {}
66
+ else
67
+ {}
68
+ end
69
+
70
+ merged = deep_merge(existing, RWM_HOOKS)
71
+ File.write(config_path, YAML.dump(merged))
72
+ end
73
+
74
+ def create_hook_scripts
75
+ create_script("pre_push", "rwm_check", PRE_PUSH_SCRIPT)
76
+ create_script("post_commit", "rwm_graph", POST_COMMIT_SCRIPT)
77
+ end
78
+
79
+ def create_script(hook_dir, name, content)
80
+ dir = File.join(@root, ".git-hooks", hook_dir)
81
+ FileUtils.mkdir_p(dir)
82
+ path = File.join(dir, name)
83
+ File.write(path, content)
84
+ File.chmod(0o755, path)
85
+ end
86
+
87
+ def deep_merge(base, override)
88
+ result = base.dup
89
+ override.each do |key, value|
90
+ if result[key].is_a?(Hash) && value.is_a?(Hash)
91
+ result[key] = deep_merge(result[key], value)
92
+ else
93
+ result[key] = value
94
+ end
95
+ end
96
+ result
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "open3"
5
+
6
+ module Rwm
7
+ class Package
8
+ attr_reader :name, :path, :type
9
+
10
+ def initialize(name:, path:, type:)
11
+ @name = name
12
+ @path = File.expand_path(path)
13
+ @type = type.to_sym
14
+ end
15
+
16
+ def lib?
17
+ type == :lib
18
+ end
19
+
20
+ def app?
21
+ type == :app
22
+ end
23
+
24
+ def has_rakefile?
25
+ File.exist?(File.join(path, "Rakefile"))
26
+ end
27
+
28
+ def has_rake_task?(task)
29
+ return false unless has_rakefile?
30
+
31
+ output, _, status = Open3.capture3("bundle", "exec", "rake", "-P", chdir: path)
32
+ return false unless status.success?
33
+
34
+ output.lines.any? { |line| line.strip == "rake #{task}" }
35
+ end
36
+
37
+ def gemfile_path
38
+ File.join(path, "Gemfile")
39
+ end
40
+
41
+ def gemspec_path
42
+ Dir.glob(File.join(path, "*.gemspec")).first
43
+ end
44
+
45
+ def relative_path(workspace_root)
46
+ Pathname.new(path).relative_path_from(Pathname.new(workspace_root)).to_s
47
+ end
48
+
49
+ def to_s
50
+ "#{name} (#{type})"
51
+ end
52
+
53
+ def ==(other)
54
+ other.is_a?(Package) && name == other.name && path == other.path
55
+ end
56
+ alias eql? ==
57
+
58
+ def hash
59
+ [name, path].hash
60
+ end
61
+ end
62
+ end
data/lib/rwm/rake.rb ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rake DSL extension for rwm workspaces.
4
+ #
5
+ # Usage in a package Rakefile:
6
+ #
7
+ # require "rwm/rake"
8
+ #
9
+ # cacheable_task :spec do
10
+ # sh "bundle exec rspec"
11
+ # end
12
+ #
13
+ # cacheable_task :build, output: "pkg/*.gem" do
14
+ # sh "gem build *.gemspec"
15
+ # end
16
+
17
+ require "rake"
18
+ require "json"
19
+
20
+ module Rwm
21
+ module RakeCache
22
+ @declarations = {}
23
+
24
+ class << self
25
+ attr_reader :declarations
26
+
27
+ def register(task_name, output:)
28
+ @declarations[task_name.to_s] = { "output" => output }
29
+ ensure_cache_config_task
30
+ end
31
+
32
+ def reset!
33
+ @declarations = {}
34
+ end
35
+
36
+ def ensure_cache_config_task
37
+ return if Rake::Task.task_defined?("rwm:cache_config")
38
+
39
+ Rake::Task.define_task("rwm:cache_config") do
40
+ puts JSON.generate(Rwm::RakeCache.declarations)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ # Top-level DSL method available in Rakefiles
48
+ def cacheable_task(name, output: nil, &block)
49
+ Rwm::RakeCache.register(name, output: output)
50
+ Rake::Task.define_task(name, &block)
51
+ end