ruby_workspace_manager 0.6.2 → 0.6.4
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 +4 -4
- data/README.md +24 -733
- data/bin/rwm +1 -1
- data/lib/rwm/affected_detector.rb +8 -0
- data/lib/rwm/cli.rb +4 -4
- data/lib/rwm/commands/bootstrap.rb +20 -2
- data/lib/rwm/commands/graph.rb +9 -0
- data/lib/rwm/commands/run.rb +18 -8
- data/lib/rwm/dependency_graph.rb +18 -0
- data/lib/rwm/gemfile.rb +7 -1
- data/lib/rwm/rake.rb +15 -3
- data/lib/rwm/task_cache.rb +1 -1
- data/lib/rwm/task_runner.rb +1 -1
- data/lib/rwm/version.rb +1 -1
- data/lib/rwm.rb +8 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 38623d16221111f5899c657ecd424a14397cb4b33223ee7d8f95ebd766c61777
|
|
4
|
+
data.tar.gz: 99992c68cb96d2ce4a7414e2ec5ee0fe5aed244c5fbcf9e3d70887090c1c155b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5f1651f55f29fe8cff0bbe731941e52baf5d173139cd62cd1c75ad80d99ae8008a2caf4ef3bea261a27fb8642d74006599eaa30919d1d16349678e4b1a08a927
|
|
7
|
+
data.tar.gz: f663aebaa0ddaabb9032a6809105eab4df406170eda4f1ed2b540340cebdb16150e9fed5f4df745ca711330b0cc431b156623d9d61e0bb9cb1a4b7ddd34150e4
|
data/README.md
CHANGED
|
@@ -9,102 +9,32 @@ A monorepo tool for Ruby, inspired by [Nx](https://nx.dev). Convention-over-conf
|
|
|
9
9
|
|
|
10
10
|
RWM discovers packages in your repository, builds a dependency graph from Gemfiles, runs tasks in parallel respecting dependency order, detects which packages are affected by a change, and caches results so unchanged work is never repeated.
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## Is this for me?
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
- [Core concepts](#core-concepts)
|
|
16
|
-
- [Workspace layout](#workspace-layout)
|
|
17
|
-
- [Managing packages](#managing-packages)
|
|
18
|
-
- [Dependencies between packages](#dependencies-between-packages)
|
|
19
|
-
- [The dependency graph](#the-dependency-graph)
|
|
20
|
-
- [Running tasks](#running-tasks)
|
|
21
|
-
- [Task caching](#task-caching)
|
|
22
|
-
- [Affected detection](#affected-detection)
|
|
23
|
-
- [Bootstrap and daily workflow](#bootstrap-and-daily-workflow)
|
|
24
|
-
- [Git hooks](#git-hooks)
|
|
25
|
-
- [Convention enforcement](#convention-enforcement)
|
|
26
|
-
- [Rails and Zeitwerk](#rails-and-zeitwerk)
|
|
27
|
-
- [VSCode integration](#vscode-integration)
|
|
28
|
-
- [Shell completions](#shell-completions)
|
|
29
|
-
- [Command reference](#command-reference)
|
|
30
|
-
- [Design philosophy](#design-philosophy)
|
|
31
|
-
- [Resources](#resources)
|
|
14
|
+
RWM is useful whenever you have **multiple Ruby packages in one repository** and want structure around them. Common scenarios:
|
|
32
15
|
|
|
33
|
-
|
|
16
|
+
- **Multiple apps sharing internal libraries** (auth, billing, notifications) — you want clear dependency boundaries, parallel test runs, and CI that only tests what changed.
|
|
17
|
+
- **Outgrowing a single Rails app** — you're extracting shared code into libraries that multiple apps consume, and you want to manage the dependencies between them without setting up a private gem server.
|
|
18
|
+
- **Several Rails apps that share domain logic** — you want one `git clone`, one `bootstrap` command, and a dependency graph that keeps everything honest.
|
|
34
19
|
|
|
35
|
-
|
|
20
|
+
RWM orchestrates Rake tasks. If you can express it as a Rake task, RWM can run it across your packages — tests, linting, builds, deploys, gem publishing, whatever your workflow needs.
|
|
36
21
|
|
|
37
|
-
|
|
22
|
+
## Quick start
|
|
38
23
|
|
|
39
24
|
```sh
|
|
40
25
|
gem install ruby_workspace_manager
|
|
41
26
|
|
|
42
27
|
mkdir my-project && cd my-project
|
|
43
28
|
git init
|
|
44
|
-
rwm init
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
`rwm init` creates the full workspace structure — `libs/`, `apps/`, a root Gemfile (with `rake` and `ruby_workspace_manager`), a root Rakefile, and adds `.rwm/` to `.gitignore`. It then runs `rwm bootstrap` automatically. The command is idempotent.
|
|
48
|
-
|
|
49
|
-
### Existing project
|
|
50
|
-
|
|
51
|
-
If you already have a git repo with a Gemfile, add RWM to it and initialize:
|
|
52
|
-
|
|
53
|
-
```sh
|
|
54
|
-
bundle add ruby_workspace_manager
|
|
55
|
-
rwm init
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
`rwm init` won't overwrite your existing Gemfile or Rakefile — it only creates files that are missing.
|
|
29
|
+
rwm init # creates workspace structure, bootstraps everything
|
|
59
30
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
```sh
|
|
63
|
-
rwm new lib auth
|
|
31
|
+
rwm new lib auth # scaffold a library
|
|
64
32
|
rwm new lib billing
|
|
65
|
-
rwm new app api
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
Each command scaffolds a complete gem structure: Gemfile, gemspec, Rakefile, module stub, and test helper (unless `--test=none`).
|
|
69
|
-
|
|
70
|
-
### Declaring dependencies
|
|
71
|
-
|
|
72
|
-
Edit the consuming package's Gemfile:
|
|
73
|
-
|
|
74
|
-
```ruby
|
|
75
|
-
# apps/api/Gemfile
|
|
76
|
-
require "rwm/gemfile"
|
|
33
|
+
rwm new app api # scaffold an application
|
|
77
34
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
Then bootstrap to install deps and rebuild the graph:
|
|
83
|
-
|
|
84
|
-
```sh
|
|
85
|
-
rwm bootstrap
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
### Running tasks
|
|
89
|
-
|
|
90
|
-
```sh
|
|
91
|
-
rwm spec # runs `rake spec` in every package that defines it
|
|
92
|
-
rwm lint # any unrecognized command is a task shortcut
|
|
93
|
-
rwm lint auth # run a task in a single package
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
Tasks run in parallel, respecting dependency order. Packages that don't define the requested task are silently skipped.
|
|
97
|
-
|
|
98
|
-
```
|
|
99
|
-
$ rwm spec
|
|
100
|
-
Running `rake spec` across 4 package(s)...
|
|
101
|
-
|
|
102
|
-
[auth] 12 examples, 0 failures
|
|
103
|
-
[billing] 8 examples, 0 failures
|
|
104
|
-
[notifications] 5 examples, 0 failures
|
|
105
|
-
[api] 21 examples, 0 failures
|
|
106
|
-
|
|
107
|
-
4 package(s): 4 passed.
|
|
35
|
+
rwm spec # runs `rake spec` in every package, in parallel
|
|
36
|
+
rwm spec --affected # only packages changed on this branch
|
|
37
|
+
rwm spec auth billing # only specific packages
|
|
108
38
|
```
|
|
109
39
|
|
|
110
40
|
## Core concepts
|
|
@@ -150,656 +80,17 @@ A directory is recognized as a package if it lives directly inside `libs/` or `a
|
|
|
150
80
|
|
|
151
81
|
The `.rwm/` directory is created automatically and gitignored by `rwm init`. It stores the dependency graph cache and task cache state.
|
|
152
82
|
|
|
153
|
-
##
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
Package names must match `/\A[a-z][a-z0-9_]*\z/` (lowercase, letters/digits/underscores, starts with a letter).
|
|
165
|
-
|
|
166
|
-
The `--test` flag controls which test framework is scaffolded. Values: `rspec` (default), `minitest`, `none`.
|
|
167
|
-
|
|
168
|
-
The scaffold includes:
|
|
169
|
-
|
|
170
|
-
- **Gemfile** — Sources rubygems.org, loads the gemspec, includes development dependencies (`rake`, the chosen test gem, `ruby_workspace_manager`), and requires `rwm/gemfile` for the `rwm_lib` helper.
|
|
171
|
-
- **Gemspec** — Minimal spec. Libraries use `require_paths = ["lib"]` and declare `spec.files`; applications use `require_paths = ["app"]` and omit `spec.files`.
|
|
172
|
-
- **Rakefile** — A `cacheable_task` for the test framework (`:spec` for rspec, `:test` for minitest) plus an empty `:bootstrap` task. With `--test=none`, only the bootstrap task is generated.
|
|
173
|
-
- **Source file** — `lib/<name>.rb` for libraries, `app/<name>.rb` for applications. Module stub.
|
|
174
|
-
- **Test helper** — `spec/spec_helper.rb` for rspec, `test/test_helper.rb` for minitest. Omitted with `--test=none`.
|
|
175
|
-
|
|
176
|
-
### Inspecting and listing
|
|
177
|
-
|
|
178
|
-
```sh
|
|
179
|
-
rwm info auth # type, path, dependencies, direct/transitive dependents
|
|
180
|
-
rwm list # formatted table of all packages
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
## Dependencies between packages
|
|
184
|
-
|
|
185
|
-
### How dependency detection works
|
|
186
|
-
|
|
187
|
-
RWM reads each package's Gemfile using Bundler's DSL parser and extracts gems declared with a `path:` option pointing into the workspace. It does not scan source code for `require` statements. This means Bundler's Gemfile is the single source of truth for both runtime resolution and RWM's dependency graph.
|
|
188
|
-
|
|
189
|
-
### The `rwm_lib` helper
|
|
190
|
-
|
|
191
|
-
Scaffolded packages include `require "rwm/gemfile"` in their Gemfile, which adds the `rwm_lib` method to Bundler's DSL:
|
|
192
|
-
|
|
193
|
-
```ruby
|
|
194
|
-
# libs/billing/Gemfile
|
|
195
|
-
require "rwm/gemfile"
|
|
196
|
-
|
|
197
|
-
rwm_lib "auth"
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
This expands to:
|
|
201
|
-
|
|
202
|
-
```ruby
|
|
203
|
-
gem "auth", path: "/absolute/path/to/libs/auth"
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
The workspace root is resolved via `git rev-parse --show-toplevel`, so it works regardless of where you run the command. You can pass any extra options that `gem` accepts:
|
|
207
|
-
|
|
208
|
-
```ruby
|
|
209
|
-
rwm_lib "auth", require: false
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
There is no `rwm_app` helper. Applications are leaf nodes — nothing should depend on them.
|
|
213
|
-
|
|
214
|
-
You can also use raw `gem ... path:` syntax directly. Both work identically for dependency detection.
|
|
215
|
-
|
|
216
|
-
### Transitive resolution
|
|
217
|
-
|
|
218
|
-
When you call `rwm_lib "auth"`, RWM automatically resolves auth's own workspace dependencies. If auth's Gemfile declares `rwm_lib "core"`, then `core` is added to your bundle automatically.
|
|
219
|
-
|
|
220
|
-
This works recursively. Diamond dependencies and cycles are handled safely (each lib is resolved at most once).
|
|
221
|
-
|
|
222
|
-
```ruby
|
|
223
|
-
# apps/web/Gemfile — only the direct dep is needed
|
|
224
|
-
require "rwm/gemfile"
|
|
225
|
-
|
|
226
|
-
rwm_lib "auth" # core (auth's dep) is added automatically
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
Transitive resolution uses `Bundler::Dsl.eval_gemfile` — the same mechanism Bundler uses internally. Options passed to the direct `rwm_lib` call (like `group:` or `require:`) are not forwarded to transitive deps.
|
|
230
|
-
|
|
231
|
-
## The dependency graph
|
|
232
|
-
|
|
233
|
-
### Building
|
|
234
|
-
|
|
235
|
-
```sh
|
|
236
|
-
rwm graph
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
Parses every package's Gemfile, constructs a DAG using Ruby's `TSort` module (Tarjan's algorithm), writes it to `.rwm/graph.json`, and prints a summary.
|
|
240
|
-
|
|
241
|
-
### Caching and staleness
|
|
242
|
-
|
|
243
|
-
Most commands (`run`, `list`, `check`, `affected`, `info`) load the graph from `.rwm/graph.json` rather than re-parsing Gemfiles. If any package's Gemfile has a modification time newer than the cache file, the graph is silently rebuilt. You rarely need to run `rwm graph` manually.
|
|
244
|
-
|
|
245
|
-
Concurrent `rwm` processes are safe — graph reads use shared file locks and writes use exclusive file locks.
|
|
246
|
-
|
|
247
|
-
### Visualization
|
|
248
|
-
|
|
249
|
-
```sh
|
|
250
|
-
rwm graph --dot # Graphviz DOT format
|
|
251
|
-
rwm graph --mermaid # Mermaid flowchart format
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
Pipe DOT output to Graphviz to render an image:
|
|
255
|
-
|
|
256
|
-
```sh
|
|
257
|
-
rwm graph --dot | dot -Tpng -o graph.png
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
Or paste Mermaid output into any Mermaid-compatible renderer (GitHub markdown, Mermaid Live Editor, etc.).
|
|
261
|
-
|
|
262
|
-
## Running tasks
|
|
263
|
-
|
|
264
|
-
### Basic usage
|
|
265
|
-
|
|
266
|
-
```sh
|
|
267
|
-
rwm run <task> # run in all packages
|
|
268
|
-
rwm run <task> <package> # run in one package
|
|
269
|
-
rwm spec # shortcut for `rwm run spec`
|
|
270
|
-
rwm lint auth # shortcut for `rwm run lint auth`
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
Any command that isn't a built-in subcommand is treated as a task name and forwarded to `rwm run`.
|
|
274
|
-
|
|
275
|
-
RWM runs `bundle exec rake <task>` in each package directory that has a Rakefile. Packages that don't define the requested task are automatically detected and silently skipped.
|
|
276
|
-
|
|
277
|
-
### Parallel execution
|
|
278
|
-
|
|
279
|
-
RWM uses a DAG scheduler with a thread pool. Each package starts executing the instant all of its dependencies have completed. If A and B are independent, they run simultaneously. If C depends on A, C starts as soon as A finishes — it does not wait for B.
|
|
280
|
-
|
|
281
|
-
The default concurrency is `Etc.nprocessors` (number of CPU cores). Override with:
|
|
282
|
-
|
|
283
|
-
```sh
|
|
284
|
-
rwm run spec --concurrency 4
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
### Output modes
|
|
288
|
-
|
|
289
|
-
**Streaming (default)** — Output is printed as it happens, prefixed with the package name:
|
|
290
|
-
|
|
291
|
-
```
|
|
292
|
-
[auth] 5 examples, 0 failures
|
|
293
|
-
[billing] 3 examples, 0 failures
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
**Buffered** — Each package's output is collected and printed as a complete block when it finishes. Failed packages have their output sent to stderr:
|
|
297
|
-
|
|
298
|
-
```sh
|
|
299
|
-
rwm run spec --buffered
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
### Failure handling
|
|
303
|
-
|
|
304
|
-
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
|
-
|
|
306
|
-
## Task caching
|
|
307
|
-
|
|
308
|
-
### Why caching matters
|
|
309
|
-
|
|
310
|
-
In a monorepo with many packages, most runs touch only a few. Without caching, `rwm spec` re-runs everything even if nothing changed. Task caching skips packages whose inputs are unchanged.
|
|
311
|
-
|
|
312
|
-
### Content-hash caching
|
|
313
|
-
|
|
314
|
-
RWM's cache is inspired by [DJB's redo](https://cr.yp.to/redo.html). The core insight: **use content hashes, not timestamps, to decide what needs rebuilding.** Timestamps are fragile — `git checkout` changes them, rebasing rewrites them. Content hashes are deterministic: if the bytes haven't changed, the result is still valid.
|
|
315
|
-
|
|
316
|
-
For each (package, task) pair, RWM:
|
|
317
|
-
|
|
318
|
-
1. **Computes a content hash** — SHA256 of all source files in the package (sorted by path), plus the content hashes of all dependency packages.
|
|
319
|
-
2. **Compares with stored hash** — If the hash matches the last successful run and declared outputs exist, the task is skipped.
|
|
320
|
-
3. **Stores on success** — After a successful run, the hash is saved to `.rwm/cache/<package>-<task>`.
|
|
321
|
-
|
|
322
|
-
Source files are discovered via `git ls-files` (tracked + untracked-but-not-ignored), so anything in `.gitignore` is excluded from the hash.
|
|
323
|
-
|
|
324
|
-
### Transitive invalidation
|
|
325
|
-
|
|
326
|
-
A package's content hash includes the content hashes of its dependencies, recursively:
|
|
327
|
-
|
|
328
|
-
```
|
|
329
|
-
hash(auth) = SHA256(auth's files)
|
|
330
|
-
hash(billing) = SHA256(billing's files + hash(auth))
|
|
331
|
-
hash(api) = SHA256(api's files + hash(billing) + hash(auth))
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
Change a single file in `auth` and the hashes of `billing` and `api` change automatically. No explicit invalidation logic needed.
|
|
335
|
-
|
|
336
|
-
### Where the cache is coarser than redo
|
|
337
|
-
|
|
338
|
-
True redo tracks exactly which files a build step read during execution. RWM hashes every git-tracked file in the package directory. This means editing a README invalidates the spec cache even though RSpec never reads it.
|
|
339
|
-
|
|
340
|
-
This is a deliberate tradeoff. File-level read tracking would require filesystem interception (`strace`, `dtrace`, FUSE), which contradicts the zero-dependency philosophy. Package-level hashing may give false invalidations (unnecessary re-runs) but never false cache hits (skipping when it shouldn't).
|
|
341
|
-
|
|
342
|
-
### Declaring cacheable tasks
|
|
343
|
-
|
|
344
|
-
Tasks are only cached if declared with `cacheable_task` in the Rakefile:
|
|
345
|
-
|
|
346
|
-
```ruby
|
|
347
|
-
# libs/auth/Rakefile
|
|
348
|
-
require "rwm/rake"
|
|
349
|
-
|
|
350
|
-
cacheable_task :spec do
|
|
351
|
-
sh "bundle exec rspec"
|
|
352
|
-
end
|
|
353
|
-
|
|
354
|
-
cacheable_task :build, output: "pkg/*.gem" do
|
|
355
|
-
sh "gem build *.gemspec"
|
|
356
|
-
end
|
|
357
|
-
```
|
|
358
|
-
|
|
359
|
-
`cacheable_task` creates a normal Rake task — it works like `task` when run directly. The caching metadata is only used when RWM orchestrates the run.
|
|
360
|
-
|
|
361
|
-
The optional `output:` parameter declares a glob for expected output files. If declared outputs don't exist, the cache is invalid even if the input hash matches.
|
|
362
|
-
|
|
363
|
-
Tasks declared with plain `task` always run unconditionally.
|
|
364
|
-
|
|
365
|
-
### Bypassing the cache
|
|
366
|
-
|
|
367
|
-
```sh
|
|
368
|
-
rwm run spec --no-cache
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
### Sharing the cache
|
|
372
|
-
|
|
373
|
-
The `.rwm/` directory is gitignored by design — committing it would create constant merge conflicts as the cache and graph change with every task run. Instead, treat your main branch CI as the single source of truth and distribute the cache from there.
|
|
374
|
-
|
|
375
|
-
Cache entries are content hashes with no absolute paths or machine-specific data. They're fully portable across machines. Restoring a stale cache is always safe — stale entries won't match and the task simply re-runs.
|
|
376
|
-
|
|
377
|
-
**The pattern:**
|
|
378
|
-
|
|
379
|
-
1. Main branch CI runs the full test suite, producing a complete `.rwm/` cache.
|
|
380
|
-
2. Feature branch CI restores main's cache, then runs only `--affected` packages.
|
|
381
|
-
3. Developer machines download the cache during `rwm bootstrap`, so new branches start pre-warmed.
|
|
382
|
-
|
|
383
|
-
The result: feature branch CI and local development only run what actually changed.
|
|
384
|
-
|
|
385
|
-
#### GitHub Actions
|
|
386
|
-
|
|
387
|
-
```yaml
|
|
388
|
-
name: CI
|
|
389
|
-
|
|
390
|
-
on:
|
|
391
|
-
push:
|
|
392
|
-
branches: [main]
|
|
393
|
-
pull_request:
|
|
394
|
-
|
|
395
|
-
jobs:
|
|
396
|
-
test:
|
|
397
|
-
runs-on: ubuntu-latest
|
|
398
|
-
steps:
|
|
399
|
-
- uses: actions/checkout@v4
|
|
400
|
-
|
|
401
|
-
- name: Fetch base branch for affected detection
|
|
402
|
-
if: github.ref != 'refs/heads/main'
|
|
403
|
-
run: git fetch origin main --depth=1
|
|
404
|
-
|
|
405
|
-
- name: Set up Ruby
|
|
406
|
-
uses: ruby/setup-ruby@v1
|
|
407
|
-
with:
|
|
408
|
-
ruby-version: "3.4"
|
|
409
|
-
bundler-cache: true
|
|
410
|
-
|
|
411
|
-
- name: Restore RWM cache
|
|
412
|
-
uses: actions/cache@v4
|
|
413
|
-
with:
|
|
414
|
-
path: .rwm
|
|
415
|
-
key: rwm-${{ runner.os }}-${{ github.sha }}
|
|
416
|
-
restore-keys: rwm-${{ runner.os }}-
|
|
417
|
-
|
|
418
|
-
- name: Bootstrap
|
|
419
|
-
run: bundle exec rwm bootstrap
|
|
420
|
-
|
|
421
|
-
- name: Run specs
|
|
422
|
-
run: |
|
|
423
|
-
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
|
424
|
-
bundle exec rwm run spec
|
|
425
|
-
else
|
|
426
|
-
bundle exec rwm run spec --affected
|
|
427
|
-
fi
|
|
428
|
-
|
|
429
|
-
# Make cache available for local dev bootstrap
|
|
430
|
-
- name: Upload RWM cache
|
|
431
|
-
if: github.ref == 'refs/heads/main'
|
|
432
|
-
uses: actions/upload-artifact@v4
|
|
433
|
-
with:
|
|
434
|
-
name: rwm-cache
|
|
435
|
-
path: .rwm/
|
|
436
|
-
retention-days: 30
|
|
437
|
-
```
|
|
438
|
-
|
|
439
|
-
Key points:
|
|
440
|
-
|
|
441
|
-
- **Fetch base branch** — Affected detection runs `git diff main...HEAD`, which needs the base branch ref. A shallow fetch of `main` is enough — no need for `fetch-depth: 0` or a full clone.
|
|
442
|
-
- **`actions/cache`** — Caches created on the default branch are accessible to all feature branches. The `restore-keys` prefix picks up the most recent main cache automatically.
|
|
443
|
-
- **Main runs everything**, populating a complete cache. Feature branches run only `--affected` and skip anything already cached from main.
|
|
444
|
-
- **`upload-artifact`** on main makes the cache downloadable for local dev bootstrap (see below).
|
|
445
|
-
|
|
446
|
-
#### Local developer cache (optional)
|
|
447
|
-
|
|
448
|
-
Add a cache download step to your root Rakefile so `rwm bootstrap` warms the local cache automatically:
|
|
449
|
-
|
|
450
|
-
```ruby
|
|
451
|
-
# Rakefile
|
|
452
|
-
task :bootstrap do
|
|
453
|
-
restore_rwm_cache
|
|
454
|
-
end
|
|
455
|
-
|
|
456
|
-
def restore_rwm_cache
|
|
457
|
-
return if File.directory?(".rwm/cache")
|
|
458
|
-
return unless system("which gh > /dev/null 2>&1")
|
|
459
|
-
|
|
460
|
-
puts "Downloading RWM cache from CI..."
|
|
461
|
-
run_id = `gh run list --branch main --status success --workflow ci.yml --limit 1 --json databaseId --jq '.[0].databaseId'`.strip
|
|
462
|
-
if run_id.empty?
|
|
463
|
-
puts "No CI cache found. Skipping."
|
|
464
|
-
return
|
|
465
|
-
end
|
|
466
|
-
|
|
467
|
-
system("gh", "run", "download", run_id, "--name", "rwm-cache", "--dir", ".rwm")
|
|
468
|
-
puts File.directory?(".rwm/cache") ? "Cache restored." : "Cache download failed. Continuing without cache."
|
|
469
|
-
end
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
The example above uses the [GitHub CLI](https://cli.github.com/) (`gh`) to download artifacts — your setup may look different depending on your CI provider or storage backend (S3, GCS, etc.). The idea is the same: download the `.rwm/` directory from a known location during bootstrap.
|
|
473
|
-
|
|
474
|
-
After cloning and running `rwm bootstrap`, developers have a warm cache. Creating a feature branch from main and running `rwm run spec --affected` skips unchanged packages immediately.
|
|
475
|
-
|
|
476
|
-
## Affected detection
|
|
477
|
-
|
|
478
|
-
### What "affected" means
|
|
479
|
-
|
|
480
|
-
When you change code on a feature branch, the affected packages are those you directly changed plus every package that depends on them, transitively. If you change `libs/auth/` and `libs/billing/` depends on `auth` and `apps/api/` depends on `billing`, all three are affected.
|
|
481
|
-
|
|
482
|
-
### Viewing affected packages
|
|
483
|
-
|
|
484
|
-
```sh
|
|
485
|
-
rwm affected
|
|
486
|
-
```
|
|
487
|
-
|
|
488
|
-
### Running tasks on affected packages
|
|
489
|
-
|
|
490
|
-
```sh
|
|
491
|
-
rwm run spec --affected
|
|
492
|
-
```
|
|
493
|
-
|
|
494
|
-
This is the most useful command for feature branch CI. It combines affected detection with task execution — only affected packages are tested, in correct dependency order with full parallelism.
|
|
495
|
-
|
|
496
|
-
### How change detection works
|
|
497
|
-
|
|
498
|
-
RWM detects changes from three sources:
|
|
499
|
-
|
|
500
|
-
1. **Committed changes** — `git diff --name-only <base>...HEAD`
|
|
501
|
-
2. **Staged changes** — `git diff --name-only --cached`
|
|
502
|
-
3. **Unstaged changes** — `git diff --name-only`
|
|
503
|
-
|
|
504
|
-
Changed files are mapped to packages by path prefix. Use `--committed` to only consider committed changes (ignoring staged and unstaged):
|
|
505
|
-
|
|
506
|
-
```sh
|
|
507
|
-
rwm run spec --affected --committed
|
|
508
|
-
```
|
|
509
|
-
|
|
510
|
-
### Root-level changes
|
|
511
|
-
|
|
512
|
-
Files outside any package directory (like the root `Gemfile` or `Rakefile`) cause all packages to be marked as affected. This is a conservative default — root-level changes can affect the entire workspace.
|
|
513
|
-
|
|
514
|
-
However, inert files are automatically excluded from triggering a full run. The following patterns are ignored by default:
|
|
515
|
-
|
|
516
|
-
- `*.md`, `LICENSE*`, `CHANGELOG*`
|
|
517
|
-
- `.github/**`, `.vscode/**`, `.idea/**`
|
|
518
|
-
- `docs/**`, `.rwm/**`
|
|
519
|
-
|
|
520
|
-
You can add custom patterns in `.rwm/affected_ignore` (one glob per line, `#` for comments).
|
|
521
|
-
|
|
522
|
-
### Base branch auto-detection
|
|
523
|
-
|
|
524
|
-
RWM detects the base branch by reading `git symbolic-ref refs/remotes/origin/HEAD`, falling back to checking for `main` or `master` locally. Override with:
|
|
525
|
-
|
|
526
|
-
```sh
|
|
527
|
-
rwm affected --base develop
|
|
528
|
-
rwm run spec --affected --base develop
|
|
529
|
-
```
|
|
530
|
-
|
|
531
|
-
## Bootstrap and daily workflow
|
|
532
|
-
|
|
533
|
-
### What bootstrap does
|
|
534
|
-
|
|
535
|
-
`rwm bootstrap` gets a workspace into a working state:
|
|
536
|
-
|
|
537
|
-
1. Runs `bundle install` in the workspace root.
|
|
538
|
-
2. Runs `rake bootstrap` in the root (if defined — for binstubs, shared tooling, etc.).
|
|
539
|
-
3. Installs git hooks (pre-push runs `rwm check`, post-commit rebuilds the graph on Gemfile changes).
|
|
540
|
-
4. Runs `bundle install` in every package (in parallel).
|
|
541
|
-
5. Runs `rake bootstrap` in packages that define it (in parallel).
|
|
542
|
-
6. Builds and validates the dependency graph.
|
|
543
|
-
7. Updates the `.code-workspace` file (if it exists).
|
|
544
|
-
|
|
545
|
-
Both `rwm init` and `rwm bootstrap` are idempotent.
|
|
546
|
-
|
|
547
|
-
### The bootstrap rake task
|
|
548
|
-
|
|
549
|
-
Every scaffolded package includes an empty `bootstrap` task. This is where package-specific setup belongs:
|
|
550
|
-
|
|
551
|
-
```ruby
|
|
552
|
-
# libs/auth/Rakefile
|
|
553
|
-
task :bootstrap do
|
|
554
|
-
sh "bin/rails db:setup" if File.exist?("bin/rails")
|
|
555
|
-
sh "cp config/credentials.example.yml config/credentials.yml" unless File.exist?("config/credentials.yml")
|
|
556
|
-
end
|
|
557
|
-
```
|
|
558
|
-
|
|
559
|
-
Common uses: database setup, copying example config files, generating local certificates, compiling native extensions.
|
|
560
|
-
|
|
561
|
-
The key property: `rwm bootstrap` runs every package's bootstrap task automatically. Developers don't need to know which packages have special setup — they run one command and everything is handled.
|
|
562
|
-
|
|
563
|
-
### After cloning
|
|
564
|
-
|
|
565
|
-
```sh
|
|
566
|
-
git clone <repo>
|
|
567
|
-
cd <repo>
|
|
568
|
-
rwm bootstrap
|
|
569
|
-
```
|
|
570
|
-
|
|
571
|
-
Every package is installed, the graph is built, hooks are active, and the workspace is ready.
|
|
572
|
-
|
|
573
|
-
### Daily workflow
|
|
574
|
-
|
|
575
|
-
```sh
|
|
576
|
-
git pull --rebase
|
|
577
|
-
rwm bootstrap # picks up any new packages or dependency changes
|
|
578
|
-
git checkout -b my-feature
|
|
579
|
-
# ... make changes ...
|
|
580
|
-
rwm spec # run all specs
|
|
581
|
-
rwm spec --affected # or just the affected ones
|
|
582
|
-
```
|
|
583
|
-
|
|
584
|
-
The pre-push hook runs `rwm check` automatically. The post-commit hook rebuilds the graph when Gemfiles change.
|
|
585
|
-
|
|
586
|
-
## Git hooks
|
|
587
|
-
|
|
588
|
-
RWM installs two hooks during `rwm bootstrap`:
|
|
589
|
-
|
|
590
|
-
- **pre-push** — Runs `rwm check` to validate conventions before pushing. Blocks the push on failure.
|
|
591
|
-
- **post-commit** — Runs `rwm graph` if any Gemfile was changed in the commit. Keeps the cached graph in sync.
|
|
592
|
-
|
|
593
|
-
### Overcommit integration
|
|
594
|
-
|
|
595
|
-
If `.overcommit.yml` exists, RWM integrates with [Overcommit](https://github.com/sds/overcommit) — it merges hook configuration into the YAML file and creates executable hook scripts. Without Overcommit, RWM writes directly to `.git/hooks/`, appending to existing hooks rather than overwriting.
|
|
596
|
-
|
|
597
|
-
## Convention enforcement
|
|
598
|
-
|
|
599
|
-
```sh
|
|
600
|
-
rwm check
|
|
601
|
-
```
|
|
602
|
-
|
|
603
|
-
Three rules:
|
|
604
|
-
|
|
605
|
-
1. **No library depending on an application.** Libraries are shared building blocks and must not be coupled to deployment targets.
|
|
606
|
-
2. **No application depending on another application.** Applications are independent deployment units. Shared code should be extracted into a library.
|
|
607
|
-
3. **No circular dependencies.** Cycles make build ordering impossible and indicate tangled responsibilities.
|
|
608
|
-
|
|
609
|
-
Exits 0 on pass, 1 on violation. The pre-push hook runs this automatically.
|
|
610
|
-
|
|
611
|
-
## Rails and Zeitwerk
|
|
612
|
-
|
|
613
|
-
### How workspace libs work in Rails
|
|
614
|
-
|
|
615
|
-
Workspace libs declared via `rwm_lib` are path gems. The standard Rails boot sequence handles them automatically:
|
|
616
|
-
|
|
617
|
-
1. `config/boot.rb` calls `Bundler.setup` — adds all gem `lib/` directories to `$LOAD_PATH`
|
|
618
|
-
2. `config/application.rb` calls `Bundler.require(*Rails.groups)` — auto-requires every gem, including workspace libs and their transitive deps
|
|
619
|
-
3. `config/environment.rb` calls `Rails.application.initialize!` — Zeitwerk activates for the app's own code
|
|
620
|
-
|
|
621
|
-
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`.
|
|
622
|
-
|
|
623
|
-
**No special setup is needed in `application.rb`.** A standard Rails template works:
|
|
624
|
-
|
|
625
|
-
```ruby
|
|
626
|
-
# apps/web/Gemfile
|
|
627
|
-
require "rwm/gemfile"
|
|
628
|
-
|
|
629
|
-
source "https://rubygems.org"
|
|
630
|
-
gemspec
|
|
631
|
-
|
|
632
|
-
rwm_lib "auth" # transitive deps resolved automatically
|
|
633
|
-
```
|
|
634
|
-
|
|
635
|
-
```ruby
|
|
636
|
-
# apps/web/config/application.rb
|
|
637
|
-
require_relative "boot"
|
|
638
|
-
require "rails/all"
|
|
639
|
-
Bundler.require(*Rails.groups)
|
|
640
|
-
|
|
641
|
-
module Web
|
|
642
|
-
class Application < Rails::Application
|
|
643
|
-
config.load_defaults 8.0
|
|
644
|
-
end
|
|
645
|
-
end
|
|
646
|
-
```
|
|
647
|
-
|
|
648
|
-
That's it. `Bundler.require` loads `auth` and all of its transitive workspace dependencies. No manual `Rwm.require_libs`, no ordering tricks.
|
|
649
|
-
|
|
650
|
-
### A note on Zeitwerk
|
|
651
|
-
|
|
652
|
-
> [!IMPORTANT]
|
|
653
|
-
> **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.
|
|
654
|
-
|
|
655
|
-
### The practical lib workflow
|
|
656
|
-
|
|
657
|
-
**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.
|
|
658
|
-
|
|
659
|
-
**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.
|
|
660
|
-
|
|
661
|
-
At extraction time, choose how the lib is structured.
|
|
662
|
-
|
|
663
|
-
### Traditional structure (the default)
|
|
664
|
-
|
|
665
|
-
This is what `rwm new lib` scaffolds. The lib's entry point loads all sub-files eagerly with `require_relative`:
|
|
666
|
-
|
|
667
|
-
```ruby
|
|
668
|
-
# libs/auth/lib/auth.rb
|
|
669
|
-
require_relative "auth/token"
|
|
670
|
-
require_relative "auth/user"
|
|
671
|
-
|
|
672
|
-
module Auth
|
|
673
|
-
VERSION = "0.1.0"
|
|
674
|
-
end
|
|
675
|
-
```
|
|
676
|
-
|
|
677
|
-
**Pros:** Works everywhere — Rails, non-Rails, any Ruby app. Simple. Standard gem structure.
|
|
678
|
-
|
|
679
|
-
**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.
|
|
680
|
-
|
|
681
|
-
This is the right choice for most workspace libs.
|
|
682
|
-
|
|
683
|
-
### Zeitwerk-compatible structure (opt-in)
|
|
684
|
-
|
|
685
|
-
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`:
|
|
686
|
-
|
|
687
|
-
```ruby
|
|
688
|
-
# libs/auth/lib/auth.rb
|
|
689
|
-
module Auth
|
|
690
|
-
end
|
|
691
|
-
|
|
692
|
-
# libs/auth/lib/auth/token.rb — defines Auth::Token
|
|
693
|
-
# libs/auth/lib/auth/user.rb — defines Auth::User
|
|
694
|
-
# Zeitwerk auto-discovers these. No require lines needed.
|
|
695
|
-
```
|
|
696
|
-
|
|
697
|
-
Each consuming Rails app opts in by adding the lib to its autoload paths and telling Bundler not to auto-require it:
|
|
698
|
-
|
|
699
|
-
```ruby
|
|
700
|
-
# apps/web/Gemfile
|
|
701
|
-
rwm_lib "auth", require: false # Bundler won't auto-require
|
|
702
|
-
```
|
|
703
|
-
|
|
704
|
-
```ruby
|
|
705
|
-
# apps/web/config/application.rb
|
|
706
|
-
module Web
|
|
707
|
-
class Application < Rails::Application
|
|
708
|
-
config.autoload_paths << Rwm.lib_path("auth")
|
|
709
|
-
config.eager_load_paths << Rwm.lib_path("auth")
|
|
710
|
-
end
|
|
711
|
-
end
|
|
712
|
-
```
|
|
713
|
-
|
|
714
|
-
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.
|
|
715
|
-
|
|
716
|
-
**Trade-offs:**
|
|
717
|
-
|
|
718
|
-
- All consumer apps must add the lib to their autoload paths — this is a per-app decision
|
|
719
|
-
- The lib cannot use `require_relative` for its own files (Zeitwerk must control loading)
|
|
720
|
-
- Non-Rails consumers need a different loading strategy (e.g., `Zeitwerk::Loader.for_gem` or a `Dir.glob` require)
|
|
721
|
-
|
|
722
|
-
### What doesn't work
|
|
723
|
-
|
|
724
|
-
**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.
|
|
725
|
-
|
|
726
|
-
**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`.
|
|
727
|
-
|
|
728
|
-
### `Rwm.require_libs` — when you need it
|
|
729
|
-
|
|
730
|
-
For standard Rails apps, `Bundler.require` handles everything. `Rwm.require_libs` exists for edge cases:
|
|
731
|
-
|
|
732
|
-
- Non-standard Rails setups that don't call `Bundler.require`
|
|
733
|
-
- Non-Rails apps that want to load all workspace libs in one call
|
|
734
|
-
- Explicit control over when workspace libs are loaded
|
|
735
|
-
|
|
736
|
-
```ruby
|
|
737
|
-
require "rwm/rails"
|
|
738
|
-
Rwm.require_libs # requires all libs resolved by rwm_lib, idempotent
|
|
739
|
-
```
|
|
740
|
-
|
|
741
|
-
Non-Rails apps don't need any of this — `require` workspace libs from your Gemfile anywhere in your code, as with any gem.
|
|
742
|
-
|
|
743
|
-
## VSCode integration
|
|
744
|
-
|
|
745
|
-
```sh
|
|
746
|
-
rwm init --vscode
|
|
747
|
-
```
|
|
748
|
-
|
|
749
|
-
Generates a `.code-workspace` file that configures VSCode's multi-root workspace feature. Each package becomes a separate root folder in the sidebar. After initial creation, `rwm bootstrap` and `rwm new` keep the folder list updated automatically. Existing `settings`, `extensions`, `launch`, and `tasks` keys are preserved.
|
|
750
|
-
|
|
751
|
-
## Shell completions
|
|
752
|
-
|
|
753
|
-
RWM ships with completion scripts for Bash and Zsh that provide command, flag, and package name completion.
|
|
754
|
-
|
|
755
|
-
### Bash
|
|
756
|
-
|
|
757
|
-
Add to `.bashrc` or `.bash_profile`:
|
|
758
|
-
|
|
759
|
-
```bash
|
|
760
|
-
source "$(gem contents ruby_workspace_manager | grep rwm.bash)"
|
|
761
|
-
```
|
|
762
|
-
|
|
763
|
-
### Zsh
|
|
764
|
-
|
|
765
|
-
Add to `.zshrc` (before `compinit`):
|
|
766
|
-
|
|
767
|
-
```zsh
|
|
768
|
-
fpath=($(gem contents ruby_workspace_manager | grep completions/rwm.zsh | xargs dirname) $fpath)
|
|
769
|
-
autoload -Uz compinit && compinit
|
|
770
|
-
```
|
|
771
|
-
|
|
772
|
-
Both scripts dynamically discover package names by scanning `libs/` and `apps/`, so tab completion always reflects your current workspace.
|
|
773
|
-
|
|
774
|
-
## Command reference
|
|
775
|
-
|
|
776
|
-
| Command | Description |
|
|
777
|
-
|---------|-------------|
|
|
778
|
-
| `rwm init [--vscode]` | Initialize a workspace. Creates dirs, Gemfile, Rakefile, .gitignore. Runs bootstrap. Idempotent. |
|
|
779
|
-
| `rwm bootstrap` | Install deps, build graph, install hooks, run bootstrap tasks. Idempotent. |
|
|
780
|
-
| `rwm new <app\|lib> <name> [--test=FW]` | Scaffold a new package. `--test`: `rspec` (default), `minitest`, `none`. |
|
|
781
|
-
| `rwm info <name>` | Show package details: type, path, deps, dependents. |
|
|
782
|
-
| `rwm graph [--dot\|--mermaid]` | Rebuild dependency graph. Optionally output DOT or Mermaid. |
|
|
783
|
-
| `rwm check` | Validate conventions. Exit 0 on pass, 1 on failure. |
|
|
784
|
-
| `rwm list` | List all packages. |
|
|
785
|
-
| `rwm run <task> [pkg]` | Run a Rake task across packages. |
|
|
786
|
-
| `rwm <task> [pkg]` | Task shortcut: `rwm spec` = `rwm run spec`. |
|
|
787
|
-
| `rwm affected [--committed] [--base REF]` | Show affected packages. |
|
|
788
|
-
| `rwm cache clean [pkg]` | Clear cached task results. |
|
|
789
|
-
| `rwm help` | Show available commands. |
|
|
790
|
-
| `rwm version` | Show version. |
|
|
791
|
-
|
|
792
|
-
### `rwm run` flags
|
|
793
|
-
|
|
794
|
-
| Flag | Description |
|
|
795
|
-
|------|-------------|
|
|
796
|
-
| `--affected` | Only run on packages affected by current changes. |
|
|
797
|
-
| `--committed` | With `--affected`, only consider committed changes. |
|
|
798
|
-
| `--base REF` | With `--affected`, compare against REF instead of auto-detected base. |
|
|
799
|
-
| `--dry-run` | Show what would run without executing. |
|
|
800
|
-
| `--no-cache` | Bypass task caching. Force all tasks to run. |
|
|
801
|
-
| `--buffered` | Buffer output per-package and print on completion. |
|
|
802
|
-
| `--concurrency N` | Limit parallel workers. Default: number of CPU cores. |
|
|
83
|
+
## Documentation
|
|
84
|
+
|
|
85
|
+
| Guide | Description |
|
|
86
|
+
|-------|-------------|
|
|
87
|
+
| [Getting Started](docs/getting-started.md) | Install, create packages, declare dependencies, build the graph |
|
|
88
|
+
| [Running Tasks](docs/running-tasks.md) | Parallel execution, output modes, failure handling, task caching |
|
|
89
|
+
| [Affected Detection](docs/affected-detection.md) | Change detection, root-level changes, base branch configuration |
|
|
90
|
+
| [Bootstrap](docs/bootstrap.md) | Workspace setup, the bootstrap rake task, daily workflow |
|
|
91
|
+
| [Conventions and Hooks](docs/conventions-and-hooks.md) | The three rules, git hooks, Overcommit integration |
|
|
92
|
+
| [Rails Integration](docs/rails-integration.md) | Zeitwerk, traditional vs compatible structure, `Rwm.require_libs` |
|
|
93
|
+
| [Command Reference](docs/command-reference.md) | All commands, flags, shell completions, VSCode integration |
|
|
803
94
|
|
|
804
95
|
## Design philosophy
|
|
805
96
|
|
data/bin/rwm
CHANGED
|
@@ -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")
|
data/lib/rwm/cli.rb
CHANGED
|
@@ -88,14 +88,14 @@ module Rwm
|
|
|
88
88
|
|
|
89
89
|
Commands:
|
|
90
90
|
init [--vscode] Initialize a new rwm workspace
|
|
91
|
-
bootstrap
|
|
91
|
+
bootstrap [pkg...] Install deps and run bootstrap (all if none given)
|
|
92
92
|
new <type> <name> Scaffold a new app or lib
|
|
93
93
|
info <name> Show details about a package
|
|
94
94
|
graph Build and save the dependency graph
|
|
95
95
|
--dot Output in Graphviz DOT format
|
|
96
96
|
--mermaid Output in Mermaid format
|
|
97
97
|
check Validate dependency graph and conventions
|
|
98
|
-
run <task> [pkg] Run a rake task across
|
|
98
|
+
run <task> [pkg...] Run a rake task across packages (all if none given)
|
|
99
99
|
affected Show packages affected by current changes
|
|
100
100
|
--base REF Compare against REF instead of auto-detected base
|
|
101
101
|
--committed Only consider committed changes
|
|
@@ -105,8 +105,8 @@ module Rwm
|
|
|
105
105
|
version Show version
|
|
106
106
|
|
|
107
107
|
Any unrecognized command is treated as a task name:
|
|
108
|
-
rwm test
|
|
109
|
-
rwm
|
|
108
|
+
rwm test → rwm run test
|
|
109
|
+
rwm test auth billing → rwm run test auth billing
|
|
110
110
|
|
|
111
111
|
Run options (for `rwm run` and task shortcuts):
|
|
112
112
|
--affected Only run on affected packages
|
|
@@ -5,6 +5,7 @@ module Rwm
|
|
|
5
5
|
class Bootstrap
|
|
6
6
|
def initialize(argv)
|
|
7
7
|
@argv = argv
|
|
8
|
+
@package_names = argv.dup.uniq
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
def run
|
|
@@ -56,14 +57,31 @@ module Rwm
|
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
def bootstrap_packages(workspace, graph)
|
|
59
|
-
packages =
|
|
60
|
+
packages = if @package_names.any?
|
|
61
|
+
# Validate all names, then expand to include transitive deps
|
|
62
|
+
@package_names.each { |name| workspace.find_package(name) }
|
|
63
|
+
all_names = Set.new(@package_names)
|
|
64
|
+
@package_names.each do |name|
|
|
65
|
+
graph.transitive_dependencies(name).each { |dep| all_names << dep }
|
|
66
|
+
end
|
|
67
|
+
all_names.map { |name| workspace.find_package(name) }
|
|
68
|
+
else
|
|
69
|
+
workspace.packages
|
|
70
|
+
end
|
|
71
|
+
|
|
60
72
|
if packages.empty?
|
|
61
73
|
puts "==> No packages found. Skipping package bootstrap."
|
|
62
74
|
return
|
|
63
75
|
end
|
|
64
76
|
|
|
65
77
|
# Step 1: bundle install in all packages (parallel by execution level)
|
|
66
|
-
|
|
78
|
+
dep_count = packages.size - (@package_names.any? ? @package_names.uniq.size : 0)
|
|
79
|
+
label = if @package_names.any? && dep_count > 0
|
|
80
|
+
"==> Installing gems in #{packages.size} package(s) (#{@package_names.join(", ")} + #{dep_count} dependencies)..."
|
|
81
|
+
else
|
|
82
|
+
"==> Installing gems in #{packages.size} package(s)..."
|
|
83
|
+
end
|
|
84
|
+
puts label
|
|
67
85
|
install_runner = TaskRunner.new(graph, packages: packages)
|
|
68
86
|
install_runner.run_command do |pkg|
|
|
69
87
|
["bundle", "install"]
|
data/lib/rwm/commands/graph.rb
CHANGED
|
@@ -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.lib? ? "lib" : "app"}/#{pkg.name}#{dep_str}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
26
35
|
end
|
|
27
36
|
|
|
28
37
|
0
|
data/lib/rwm/commands/run.rb
CHANGED
|
@@ -21,17 +21,23 @@ module Rwm
|
|
|
21
21
|
task = @argv.shift
|
|
22
22
|
|
|
23
23
|
unless task
|
|
24
|
-
$stderr.puts "Usage: rwm run <task> [<package
|
|
24
|
+
$stderr.puts "Usage: rwm run <task> [<package>...] [--affected] [--base REF] [--dry-run] [--no-cache] [--buffered] [--concurrency N]"
|
|
25
25
|
return 1
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
package_names = @argv.dup.uniq
|
|
29
|
+
@argv.clear
|
|
30
|
+
|
|
31
|
+
if package_names.any? && @affected_only
|
|
32
|
+
$stderr.puts "Error: --affected and explicit package names are mutually exclusive."
|
|
33
|
+
return 1
|
|
34
|
+
end
|
|
29
35
|
|
|
30
36
|
workspace = Workspace.find
|
|
31
37
|
graph = DependencyGraph.load(workspace)
|
|
32
38
|
|
|
33
|
-
packages = if
|
|
34
|
-
|
|
39
|
+
packages = if package_names.any?
|
|
40
|
+
package_names.map { |name| workspace.find_package(name) }
|
|
35
41
|
elsif @affected_only
|
|
36
42
|
detector = AffectedDetector.new(workspace, graph, committed_only: @committed_only, base_branch: @base_branch)
|
|
37
43
|
affected = detector.affected_packages
|
|
@@ -100,21 +106,25 @@ module Rwm
|
|
|
100
106
|
|
|
101
107
|
passed = runner.results.count(&:passed?)
|
|
102
108
|
failed_results = runner.results.select { |r| r.failed? || r.errored? }
|
|
103
|
-
|
|
109
|
+
no_task = runner.results.count(&:skipped?)
|
|
110
|
+
dep_failed = runner.results.count(&:dep_skipped?)
|
|
104
111
|
|
|
105
112
|
total = runner.results.size
|
|
106
113
|
parts = []
|
|
107
114
|
parts << "#{passed} passed" unless passed.zero?
|
|
108
115
|
parts << "#{failed_results.size} failed" unless failed_results.empty?
|
|
109
|
-
parts << "#{
|
|
116
|
+
parts << "#{dep_failed} skipped (dep failed)" unless dep_failed.zero?
|
|
117
|
+
parts << "#{no_task} skipped (no task)" unless no_task.zero?
|
|
110
118
|
|
|
111
119
|
puts
|
|
112
120
|
puts "#{total} package(s): #{parts.join(", ")}."
|
|
113
121
|
|
|
114
122
|
passed_results = runner.results.select(&:passed?)
|
|
115
|
-
|
|
123
|
+
no_task_results = runner.results.select(&:skipped?)
|
|
124
|
+
dep_skipped_results = runner.results.select(&:dep_skipped?)
|
|
116
125
|
Rwm.debug("passed: #{passed_results.map(&:package_name).join(", ")}") unless passed_results.empty?
|
|
117
|
-
Rwm.debug("skipped (no matching task): #{
|
|
126
|
+
Rwm.debug("skipped (no matching task): #{no_task_results.map(&:package_name).join(", ")}") unless no_task_results.empty?
|
|
127
|
+
Rwm.debug("skipped (dep failed): #{dep_skipped_results.map(&:package_name).join(", ")}") unless dep_skipped_results.empty?
|
|
118
128
|
|
|
119
129
|
if failed_results.empty?
|
|
120
130
|
0
|
data/lib/rwm/dependency_graph.rb
CHANGED
|
@@ -57,6 +57,24 @@ module Rwm
|
|
|
57
57
|
visited.to_a
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# Walk the graph to find all transitive dependencies (what a package transitively depends on)
|
|
61
|
+
def transitive_dependencies(name)
|
|
62
|
+
visited = Set.new
|
|
63
|
+
queue = [name]
|
|
64
|
+
|
|
65
|
+
until queue.empty?
|
|
66
|
+
current = queue.shift
|
|
67
|
+
dependencies(current).each do |dep|
|
|
68
|
+
next if visited.include?(dep)
|
|
69
|
+
|
|
70
|
+
visited << dep
|
|
71
|
+
queue << dep
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
visited.to_a
|
|
76
|
+
end
|
|
77
|
+
|
|
60
78
|
# Topological sort (dependencies before dependents)
|
|
61
79
|
def topological_order
|
|
62
80
|
tsort
|
data/lib/rwm/gemfile.rb
CHANGED
|
@@ -53,10 +53,16 @@ module Rwm
|
|
|
53
53
|
@rwm_resolved ||= Set.new
|
|
54
54
|
return if @rwm_resolved.include?(name)
|
|
55
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
|
+
|
|
56
63
|
@rwm_resolved.add(name)
|
|
57
64
|
Rwm.resolved_libs.add(name) unless @rwm_scanning
|
|
58
65
|
|
|
59
|
-
path = File.join(rwm_workspace_root, "libs", name)
|
|
60
66
|
gem(name, **opts, path: path)
|
|
61
67
|
|
|
62
68
|
# Resolve transitive workspace deps from the target lib's Gemfile
|
data/lib/rwm/rake.rb
CHANGED
|
@@ -45,7 +45,19 @@ module Rwm
|
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
# Top-level DSL method available in Rakefiles
|
|
48
|
-
def cacheable_task(
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
def cacheable_task(*args, **opts, &block)
|
|
49
|
+
output = opts.delete(:output)
|
|
50
|
+
|
|
51
|
+
# Remaining keyword opts are Rake dependency syntax (e.g. seed: :environment)
|
|
52
|
+
args << opts unless opts.empty?
|
|
53
|
+
|
|
54
|
+
# Resolve the task name using Rake's own parser (dup because resolve_args mutates)
|
|
55
|
+
task_name, = Rake.application.resolve_args(args.dup)
|
|
56
|
+
|
|
57
|
+
Rwm::RakeCache.register(task_name, output: output)
|
|
58
|
+
|
|
59
|
+
# Clear existing actions so we replace rather than stack (e.g. rspec-rails :spec)
|
|
60
|
+
Rake::Task[task_name].clear_actions if Rake::Task.task_defined?(task_name)
|
|
61
|
+
|
|
62
|
+
Rake::Task.define_task(*args, &block)
|
|
51
63
|
end
|
data/lib/rwm/task_cache.rb
CHANGED
|
@@ -132,7 +132,7 @@ module Rwm
|
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
Rwm.debug("cache declarations: discovering for #{package.name}")
|
|
135
|
-
output, _, status = Open3.capture3("bundle", "exec", "rake", "rwm:cache_config", chdir: package.path)
|
|
135
|
+
output, _, status = Open3.capture3(Rwm.bundle_env(package.path), "bundle", "exec", "rake", "rwm:cache_config", chdir: package.path)
|
|
136
136
|
result = if status.success? && !output.strip.empty?
|
|
137
137
|
JSON.parse(output.strip)
|
|
138
138
|
else
|
data/lib/rwm/task_runner.rb
CHANGED
|
@@ -154,7 +154,7 @@ module Rwm
|
|
|
154
154
|
prefix = "[#{pkg.name}]"
|
|
155
155
|
Rwm.debug("running: #{cmd.join(' ')} in #{pkg.path}")
|
|
156
156
|
|
|
157
|
-
stdout, stderr, status = Open3.capture3(*cmd, chdir: pkg.path)
|
|
157
|
+
stdout, stderr, status = Open3.capture3(Rwm.bundle_env(pkg.path), *cmd, chdir: pkg.path)
|
|
158
158
|
output = format_output(prefix, stdout, stderr)
|
|
159
159
|
|
|
160
160
|
# Detect "task not found" and treat as skipped, not failed
|
data/lib/rwm/version.rb
CHANGED
data/lib/rwm.rb
CHANGED
|
@@ -18,6 +18,14 @@ module Rwm
|
|
|
18
18
|
$stderr.puts "[rwm debug] #{msg}" if @verbose
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# Environment hash that points BUNDLE_GEMFILE at a specific directory's Gemfile.
|
|
22
|
+
# Pass as the first argument to Open3.capture3 or system() when spawning
|
|
23
|
+
# bundle commands in package directories, so the child process resolves
|
|
24
|
+
# against the package's Gemfile instead of inheriting the root's.
|
|
25
|
+
def self.bundle_env(dir)
|
|
26
|
+
{ "BUNDLE_GEMFILE" => File.join(dir, "Gemfile") }
|
|
27
|
+
end
|
|
28
|
+
|
|
21
29
|
autoload :Workspace, "rwm/workspace"
|
|
22
30
|
autoload :Package, "rwm/package"
|
|
23
31
|
autoload :GemfileParser, "rwm/gemfile_parser"
|