ruby_workspace_manager 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbe69d99300fafa6359944aa4a1cb16a3ed9cbb0e1e544b3a428a4e04af4dc66
4
- data.tar.gz: 114e54d2ac5872ae866ecf0503b8ebdeb9c72b0db892a564e0b3889b4ff1e0fd
3
+ metadata.gz: dab8a0ae74fbf4b09e06f997a9b88d3d80e535b16c290eca3d7429fc67c1e3cd
4
+ data.tar.gz: dec2cd0382c7613fa43f62294a7ed32254355046ce33e903d023bde7cc6a2cc9
5
5
  SHA512:
6
- metadata.gz: bad333bc09182d824ef4c5b4e50fc290a7922e81a8e616a991d54cf994cca8340038fc5bb4b2dfa1612292ac7fc3f6008089ea6ff7a95b702ef65c0335ee97b4
7
- data.tar.gz: bdfb899d956de2f9aa4d694d83b74cd79cb9936c0f38cfa1f64c78931846436be644510e0fce9dcfb8774af61b74d5cc24e09769aa0b2547c6a9ffba45d1dc95
6
+ metadata.gz: 228e51efcfe4f4ec0a230eb1fd2d7c56f67464d961d6427c7df81f2da3648573920bb84b24b73d9874e461ab34ba35d1f3c2de6e8bdc442139509887ee5b50f9
7
+ data.tar.gz: 391aa7d70d6c236a99a79c02832ffc3dc22b5d897c1c6b83b3ab8d22efae9be9214c673015a69a8273ff743df85053c0b721e8fd5b607bf3424a485ebd0716bb
data/README.md CHANGED
@@ -18,12 +18,15 @@ RWM manages Ruby monorepos with multiple apps and libraries. It builds a depende
18
18
  | `rwm new <app\|lib> <name>` | Scaffold a new package. |
19
19
  | `rwm graph` | Rebuild the dependency graph. `--dot` / `--mermaid` for visualization. |
20
20
  | `rwm run <task> [pkg]` | Run a Rake task across packages. Packages without the task are skipped. |
21
- | `rwm test` | Shortcut for `rwm run test`. Also: `rwm spec`, `rwm build`. |
21
+ | `rwm <task> [pkg]` | Any unknown command is a task shortcut: `rwm test` = `rwm run test`. |
22
22
  | `rwm run <task> --affected` | Run only on packages affected by current changes. |
23
23
  | `rwm check` | Validate conventions. |
24
24
  | `rwm list` | List all packages. |
25
25
  | `rwm info <name>` | Show package details. |
26
26
  | `rwm affected` | Show affected packages. |
27
+ | `rwm cache clean [pkg]` | Clear cached task results. |
28
+
29
+ Shell completions for Bash and Zsh are included — see [GUIDE.md](GUIDE.md) for setup instructions.
27
30
 
28
31
  See [GUIDE.md](GUIDE.md) for full usage documentation — dependencies, caching, affected detection, git hooks, design decisions, and more.
29
32
 
@@ -0,0 +1,169 @@
1
+ # Bash completion for rwm (Ruby Workspace Manager)
2
+ # Source this file in your .bashrc or .bash_profile:
3
+ # source "$(gem contents ruby_workspace_manager | grep rwm.bash)"
4
+
5
+ _rwm_find_workspace_root() {
6
+ local dir="$PWD"
7
+ while [[ "$dir" != "/" ]]; do
8
+ if [[ -d "$dir/.git" ]]; then
9
+ echo "$dir"
10
+ return
11
+ fi
12
+ dir="$(dirname "$dir")"
13
+ done
14
+ }
15
+
16
+ _rwm_packages() {
17
+ local root
18
+ root="$(_rwm_find_workspace_root)"
19
+ [[ -z "$root" ]] && return
20
+
21
+ local dir
22
+ for dir in "$root"/libs/*/ "$root"/apps/*/; do
23
+ [[ -d "$dir" && -f "$dir/Gemfile" ]] && echo "${dir%/}" | xargs basename
24
+ done
25
+ }
26
+
27
+ _rwm() {
28
+ local cur prev words cword
29
+ _init_completion || return
30
+
31
+ local commands="init bootstrap new info graph check list run affected cache help version"
32
+ local global_flags="--verbose --help --version"
33
+
34
+ # Find the command (first non-flag argument after rwm)
35
+ local cmd="" cmd_index=0
36
+ local i
37
+ for ((i = 1; i < cword; i++)); do
38
+ case "${words[i]}" in
39
+ --verbose) ;;
40
+ -*) ;;
41
+ *)
42
+ cmd="${words[i]}"
43
+ cmd_index=$i
44
+ break
45
+ ;;
46
+ esac
47
+ done
48
+
49
+ # No command yet — complete commands and global flags
50
+ if [[ -z "$cmd" ]]; then
51
+ COMPREPLY=($(compgen -W "$commands $global_flags" -- "$cur"))
52
+ return
53
+ fi
54
+
55
+ # Per-command completions
56
+ case "$cmd" in
57
+ init)
58
+ COMPREPLY=($(compgen -W "--vscode" -- "$cur"))
59
+ ;;
60
+ bootstrap | check | list | help | version)
61
+ ;;
62
+ new)
63
+ local pos=$((cword - cmd_index))
64
+ if [[ $pos -eq 1 ]]; then
65
+ COMPREPLY=($(compgen -W "app lib" -- "$cur"))
66
+ fi
67
+ ;;
68
+ info)
69
+ local pos=$((cword - cmd_index))
70
+ if [[ $pos -eq 1 ]]; then
71
+ COMPREPLY=($(compgen -W "$(_rwm_packages)" -- "$cur"))
72
+ fi
73
+ ;;
74
+ graph)
75
+ COMPREPLY=($(compgen -W "--dot --mermaid" -- "$cur"))
76
+ ;;
77
+ affected)
78
+ case "$prev" in
79
+ --base) ;;
80
+ *)
81
+ COMPREPLY=($(compgen -W "--base --committed" -- "$cur"))
82
+ ;;
83
+ esac
84
+ ;;
85
+ cache)
86
+ local pos=$((cword - cmd_index))
87
+ if [[ $pos -eq 1 ]]; then
88
+ COMPREPLY=($(compgen -W "clean" -- "$cur"))
89
+ elif [[ $pos -eq 2 ]]; then
90
+ COMPREPLY=($(compgen -W "$(_rwm_packages)" -- "$cur"))
91
+ fi
92
+ ;;
93
+ run)
94
+ local run_flags="--affected --committed --base --dry-run --no-cache --buffered --concurrency"
95
+ case "$prev" in
96
+ --base | --concurrency)
97
+ # These flags expect a value; don't complete
98
+ ;;
99
+ *)
100
+ # Count positional args (skip flags and flag values)
101
+ local positionals=0
102
+ local skip_next=false
103
+ for ((i = cmd_index + 1; i < cword; i++)); do
104
+ if $skip_next; then
105
+ skip_next=false
106
+ continue
107
+ fi
108
+ case "${words[i]}" in
109
+ --base | --concurrency)
110
+ skip_next=true
111
+ ;;
112
+ -*)
113
+ ;;
114
+ *)
115
+ ((positionals++))
116
+ ;;
117
+ esac
118
+ done
119
+
120
+ if [[ "$cur" == -* ]]; then
121
+ COMPREPLY=($(compgen -W "$run_flags" -- "$cur"))
122
+ elif [[ $positionals -eq 0 ]]; then
123
+ # First positional: task name (no completion — user-defined)
124
+ :
125
+ elif [[ $positionals -eq 1 ]]; then
126
+ # Second positional: package name
127
+ COMPREPLY=($(compgen -W "$(_rwm_packages)" -- "$cur"))
128
+ fi
129
+ ;;
130
+ esac
131
+ ;;
132
+ *)
133
+ # Any unrecognized command is a task shortcut — offer run flags and packages
134
+ local run_flags="--affected --committed --base --dry-run --no-cache --buffered --concurrency"
135
+ case "$prev" in
136
+ --base | --concurrency)
137
+ ;;
138
+ *)
139
+ local positionals=0
140
+ local skip_next=false
141
+ for ((i = cmd_index + 1; i < cword; i++)); do
142
+ if $skip_next; then
143
+ skip_next=false
144
+ continue
145
+ fi
146
+ case "${words[i]}" in
147
+ --base | --concurrency)
148
+ skip_next=true
149
+ ;;
150
+ -*)
151
+ ;;
152
+ *)
153
+ ((positionals++))
154
+ ;;
155
+ esac
156
+ done
157
+
158
+ if [[ "$cur" == -* ]]; then
159
+ COMPREPLY=($(compgen -W "$run_flags" -- "$cur"))
160
+ elif [[ $positionals -eq 0 ]]; then
161
+ COMPREPLY=($(compgen -W "$(_rwm_packages)" -- "$cur"))
162
+ fi
163
+ ;;
164
+ esac
165
+ ;;
166
+ esac
167
+ }
168
+
169
+ complete -F _rwm rwm
@@ -0,0 +1,125 @@
1
+ #compdef rwm
2
+ # Zsh completion for rwm (Ruby Workspace Manager)
3
+ # Add the completions directory to your fpath in .zshrc:
4
+ # fpath=($(gem contents ruby_workspace_manager | grep completions/rwm.zsh | xargs dirname) $fpath)
5
+ # autoload -Uz compinit && compinit
6
+
7
+ _rwm_find_workspace_root() {
8
+ local dir="${PWD}"
9
+ while [[ "$dir" != "/" ]]; do
10
+ if [[ -d "$dir/.git" ]]; then
11
+ echo "$dir"
12
+ return
13
+ fi
14
+ dir="${dir:h}"
15
+ done
16
+ }
17
+
18
+ _rwm_complete_packages() {
19
+ local root
20
+ root="$(_rwm_find_workspace_root)"
21
+ [[ -z "$root" ]] && return
22
+
23
+ local -a packages
24
+ local dir
25
+ for dir in "$root"/libs/*(N/) "$root"/apps/*(N/); do
26
+ [[ -f "$dir/Gemfile" ]] && packages+=("${dir:t}")
27
+ done
28
+ _describe 'package' packages
29
+ }
30
+
31
+ _rwm() {
32
+ local -a commands=(
33
+ 'init:Initialize a new rwm workspace'
34
+ 'bootstrap:Install deps and run bootstrap tasks in all packages'
35
+ 'new:Scaffold a new app or lib'
36
+ 'info:Show details about a package'
37
+ 'graph:Build and save the dependency graph'
38
+ 'check:Validate dependency graph and conventions'
39
+ 'list:List all packages in the workspace'
40
+ 'run:Run a rake task across all or one package'
41
+ 'affected:Show packages affected by current changes'
42
+ 'cache:Manage task cache'
43
+ 'help:Show help'
44
+ 'version:Show version'
45
+ )
46
+
47
+ local -a global_flags=(
48
+ '--verbose[Enable debug logging]'
49
+ '--help[Show help]'
50
+ '--version[Show version]'
51
+ )
52
+
53
+ local -a run_flags=(
54
+ '--affected[Only run on affected packages]'
55
+ '--committed[Only consider committed changes]'
56
+ '--base[Compare against REF instead of auto-detected base]:ref'
57
+ '--dry-run[Show what would run without executing]'
58
+ '--no-cache[Bypass task-level caching]'
59
+ '--buffered[Buffer output per-package and print on completion]'
60
+ '--concurrency[Max parallel workers]:number'
61
+ )
62
+
63
+ # If we haven't completed the first argument yet, offer commands
64
+ if (( CURRENT == 2 )); then
65
+ _describe 'command' commands
66
+ return
67
+ fi
68
+
69
+ local cmd="${words[2]}"
70
+
71
+ case "$cmd" in
72
+ init)
73
+ _arguments -s \
74
+ '--vscode[Generate VSCode .code-workspace file]' \
75
+ $global_flags
76
+ ;;
77
+ bootstrap | check | list | help | version)
78
+ ;;
79
+ new)
80
+ local -a types=('app:Application package' 'lib:Library package')
81
+ case $CURRENT in
82
+ 3) _describe 'type' types ;;
83
+ 4) _message 'package name' ;;
84
+ esac
85
+ ;;
86
+ info)
87
+ if (( CURRENT == 3 )); then
88
+ _rwm_complete_packages
89
+ fi
90
+ ;;
91
+ graph)
92
+ _arguments -s \
93
+ '--dot[Output in Graphviz DOT format]' \
94
+ '--mermaid[Output in Mermaid format]' \
95
+ $global_flags
96
+ ;;
97
+ affected)
98
+ _arguments -s \
99
+ '--base[Compare against REF instead of auto-detected base]:ref' \
100
+ '--committed[Only consider committed changes]' \
101
+ $global_flags
102
+ ;;
103
+ cache)
104
+ case $CURRENT in
105
+ 3) local -a subcmds=('clean:Clear cached task results')
106
+ _describe 'subcommand' subcmds ;;
107
+ 4) _rwm_complete_packages ;;
108
+ esac
109
+ ;;
110
+ run)
111
+ _arguments -s \
112
+ $run_flags \
113
+ '1:task' \
114
+ '2:package:_rwm_complete_packages'
115
+ ;;
116
+ *)
117
+ # Any unrecognized command is a task shortcut — offer run flags and packages
118
+ _arguments -s \
119
+ $run_flags \
120
+ '1:package:_rwm_complete_packages'
121
+ ;;
122
+ esac
123
+ }
124
+
125
+ _rwm "$@"
@@ -6,11 +6,11 @@ module Rwm
6
6
  class AffectedDetector
7
7
  attr_reader :workspace, :graph, :base_branch
8
8
 
9
- def initialize(workspace, graph, committed_only: false)
9
+ def initialize(workspace, graph, committed_only: false, base_branch: nil)
10
10
  @workspace = workspace
11
11
  @graph = graph
12
12
  @committed_only = committed_only
13
- @base_branch = detect_base_branch
13
+ @base_branch = base_branch || detect_base_branch
14
14
  end
15
15
 
16
16
  # Returns packages directly changed + their transitive dependents
@@ -68,20 +68,25 @@ module Rwm
68
68
  files = Set.new
69
69
 
70
70
  # 1. Committed changes: base branch vs HEAD
71
+ Rwm.debug("affected: git diff --name-only #{base_branch}...HEAD")
71
72
  committed, _, status = Open3.capture3("git", "-C", workspace.root, "diff", "--name-only", "#{base_branch}...HEAD")
72
73
  committed.lines.each { |l| files << l.chomp } if status.success?
73
74
 
74
75
  unless @committed_only
75
76
  # 2. Staged changes (not yet committed)
77
+ Rwm.debug("affected: git diff --name-only --cached")
76
78
  staged, _, status = Open3.capture3("git", "-C", workspace.root, "diff", "--name-only", "--cached")
77
79
  staged.lines.each { |l| files << l.chomp } if status.success?
78
80
 
79
81
  # 3. Unstaged working directory changes
82
+ Rwm.debug("affected: git diff --name-only")
80
83
  unstaged, _, status = Open3.capture3("git", "-C", workspace.root, "diff", "--name-only")
81
84
  unstaged.lines.each { |l| files << l.chomp } if status.success?
82
85
  end
83
86
 
84
- files.reject(&:empty?).to_a
87
+ result = files.reject(&:empty?).to_a
88
+ Rwm.debug("affected: #{result.size} changed file(s) detected")
89
+ result
85
90
  end
86
91
 
87
92
  def map_files_to_packages(files)
data/lib/rwm/cli.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "optparse"
4
-
5
3
  module Rwm
6
4
  class CLI
7
5
  COMMANDS = {
@@ -13,12 +11,10 @@ module Rwm
13
11
  "check" => "Commands::Check",
14
12
  "list" => "Commands::List",
15
13
  "run" => "Commands::Run",
16
- "affected" => "Commands::Affected"
14
+ "affected" => "Commands::Affected",
15
+ "cache" => "Commands::Cache"
17
16
  }.freeze
18
17
 
19
- # Shortcuts that expand to `run <task>`
20
- TASK_SHORTCUTS = %w[test spec build].freeze
21
-
22
18
  def self.run(argv)
23
19
  new(argv).run
24
20
  end
@@ -28,6 +24,8 @@ module Rwm
28
24
  end
29
25
 
30
26
  def run
27
+ parse_global_flags
28
+
31
29
  command_name = @argv.shift
32
30
 
33
31
  if command_name.nil? || %w[-h --help help].include?(command_name)
@@ -42,21 +40,15 @@ module Rwm
42
40
 
43
41
  check_required_tools
44
42
 
45
- # Expand task shortcuts: `rwm test` → `rwm run test`
46
- if TASK_SHORTCUTS.include?(command_name)
43
+ # Unknown commands are treated as task names: `rwm test` → `rwm run test`
44
+ unless COMMANDS.key?(command_name)
47
45
  @argv.unshift(command_name)
48
46
  command_name = "run"
49
47
  end
50
48
 
51
- const_name = COMMANDS[command_name]
52
- unless const_name
53
- $stderr.puts "Unknown command: #{command_name}"
54
- $stderr.puts "Run `rwm help` for available commands."
55
- return 1
56
- end
57
-
58
49
  # Autoload the command
59
50
  require "rwm/commands/#{command_name}"
51
+ const_name = COMMANDS[command_name]
60
52
  command_class = const_name.split("::").reduce(Rwm) { |mod, name| mod.const_get(name) }
61
53
  command_class.new(@argv).run
62
54
  rescue Rwm::Error => e
@@ -66,11 +58,17 @@ module Rwm
66
58
 
67
59
  private
68
60
 
61
+ def parse_global_flags
62
+ Rwm.verbose = true if ENV["RWM_DEBUG"] == "1"
63
+ if @argv.delete("--verbose")
64
+ Rwm.verbose = true
65
+ end
66
+ end
67
+
69
68
  def check_required_tools
70
69
  %w[git bundle].each do |tool|
71
70
  unless system("which", tool, out: File::NULL, err: File::NULL)
72
- $stderr.puts "Error: #{tool} is not installed or not in PATH."
73
- exit 1
71
+ raise Rwm::Error, "#{tool} is not installed or not in PATH."
74
72
  end
75
73
  end
76
74
  end
@@ -82,7 +80,7 @@ module Rwm
82
80
  Usage: rwm <command> [options]
83
81
 
84
82
  Commands:
85
- init Initialize a new rwm workspace
83
+ init [--vscode] Initialize a new rwm workspace
86
84
  bootstrap Install deps and run bootstrap tasks in all packages
87
85
  new <type> <name> Scaffold a new app or lib
88
86
  info <name> Show details about a package
@@ -91,18 +89,31 @@ module Rwm
91
89
  --mermaid Output in Mermaid format
92
90
  check Validate dependency graph and conventions
93
91
  run <task> [pkg] Run a rake task across all (or one) package(s)
94
- test Shortcut for `rwm run test`
95
92
  affected Show packages affected by current changes
93
+ --base REF Compare against REF instead of auto-detected base
94
+ --committed Only consider committed changes
96
95
  list List all packages in the workspace
96
+ cache clean [pkg] Clear cached task results
97
97
  help Show this help
98
+ version Show version
99
+
100
+ Any unrecognized command is treated as a task name:
101
+ rwm test → rwm run test
102
+ rwm lint → rwm run lint
98
103
 
99
- Run options:
104
+ Run options (for `rwm run` and task shortcuts):
100
105
  --affected Only run on affected packages
106
+ --committed Only consider committed changes (with --affected)
107
+ --base REF Compare against REF instead of auto-detected base
108
+ --dry-run Show what would run without executing
101
109
  --no-cache Bypass task-level caching
110
+ --buffered Buffer output per-package
111
+ --concurrency N Max parallel workers (default: CPU count)
102
112
 
103
- Options:
113
+ Global options:
104
114
  -h, --help Show this help
105
115
  -v, --version Show version
116
+ --verbose Enable debug logging (or set RWM_DEBUG=1)
106
117
  HELP
107
118
  end
108
119
  end
@@ -8,13 +8,14 @@ module Rwm
8
8
  def initialize(argv)
9
9
  @argv = argv
10
10
  @committed_only = false
11
+ @base_branch = nil
11
12
  parse_options
12
13
  end
13
14
 
14
15
  def run
15
16
  workspace = Workspace.find
16
17
  graph = DependencyGraph.load(workspace)
17
- detector = AffectedDetector.new(workspace, graph, committed_only: @committed_only)
18
+ detector = AffectedDetector.new(workspace, graph, committed_only: @committed_only, base_branch: @base_branch)
18
19
 
19
20
  affected = detector.affected_packages
20
21
  directly_changed = detector.directly_changed_packages
@@ -40,6 +41,9 @@ module Rwm
40
41
 
41
42
  def parse_options
42
43
  parser = OptionParser.new do |opts|
44
+ opts.on("--base REF", "Compare against REF instead of auto-detected base branch") do |ref|
45
+ @base_branch = ref
46
+ end
43
47
  opts.on("--committed", "Only consider committed changes (ignore staged/unstaged)") do
44
48
  @committed_only = true
45
49
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwm
4
+ module Commands
5
+ class Cache
6
+ def initialize(argv)
7
+ @argv = argv
8
+ end
9
+
10
+ def run
11
+ subcommand = @argv.shift
12
+
13
+ case subcommand
14
+ when "clean"
15
+ clean
16
+ when nil
17
+ $stderr.puts "Usage: rwm cache clean [<package>]"
18
+ 1
19
+ else
20
+ $stderr.puts "Unknown cache subcommand: #{subcommand}"
21
+ $stderr.puts "Usage: rwm cache clean [<package>]"
22
+ 1
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def clean
29
+ workspace = Workspace.find
30
+ package_name = @argv.shift
31
+
32
+ if package_name
33
+ workspace.find_package(package_name) # validates package exists
34
+ TaskCache.clean(workspace, package_name: package_name)
35
+ puts "Cleared cache for #{package_name}."
36
+ else
37
+ TaskCache.clean(workspace)
38
+ puts "Cleared all cached task results."
39
+ end
40
+
41
+ 0
42
+ end
43
+ end
44
+ end
45
+ end
@@ -20,9 +20,9 @@ module Rwm
20
20
 
21
21
  case @format
22
22
  when :dot
23
- puts graph.to_dot(workspace.root)
23
+ puts graph.to_dot
24
24
  when :mermaid
25
- puts graph.to_mermaid(workspace.root)
25
+ puts graph.to_mermaid
26
26
  end
27
27
 
28
28
  0
@@ -44,9 +44,10 @@ module Rwm
44
44
  puts "Workspace initialized. Running bootstrap..."
45
45
  puts
46
46
 
47
- # Call bootstrap as the last step
47
+ # Call bootstrap as the last step — return its exit code
48
48
  require "rwm/commands/bootstrap"
49
49
  Commands::Bootstrap.new([]).run
50
+ 0
50
51
  end
51
52
 
52
53
  private
@@ -53,13 +53,14 @@ module Rwm
53
53
  end
54
54
 
55
55
  def scaffold(pkg_path, name, type)
56
- FileUtils.mkdir_p(File.join(pkg_path, "lib", name))
56
+ source_dir = type == "lib" ? "lib" : "app"
57
+ FileUtils.mkdir_p(File.join(pkg_path, source_dir, name))
57
58
  FileUtils.mkdir_p(File.join(pkg_path, "spec"))
58
59
 
59
60
  write_gemfile(pkg_path, name)
60
61
  write_gemspec(pkg_path, name, type)
61
62
  write_rakefile(pkg_path, name)
62
- write_lib_entry(pkg_path, name)
63
+ write_entry_file(pkg_path, name, type)
63
64
  write_spec_helper(pkg_path)
64
65
  end
65
66
 
@@ -94,8 +95,9 @@ module Rwm
94
95
  '',
95
96
  ]
96
97
  lines << ' spec.files = Dir.glob("lib/**/*")' if type == "lib"
98
+ source_dir = type == "lib" ? "lib" : "app"
97
99
  lines.concat([
98
- ' spec.require_paths = ["lib"]',
100
+ " spec.require_paths = [\"#{source_dir}\"]",
99
101
  ' spec.required_ruby_version = ">= 3.4.0"',
100
102
  'end',
101
103
  '',
@@ -121,8 +123,9 @@ module Rwm
121
123
  RAKEFILE
122
124
  end
123
125
 
124
- def write_lib_entry(pkg_path, name)
125
- File.write(File.join(pkg_path, "lib", "#{name}.rb"), <<~RUBY)
126
+ def write_entry_file(pkg_path, name, type)
127
+ source_dir = type == "lib" ? "lib" : "app"
128
+ File.write(File.join(pkg_path, source_dir, "#{name}.rb"), <<~RUBY)
126
129
  # frozen_string_literal: true
127
130
 
128
131
  module #{camelize(name)}
@@ -12,6 +12,8 @@ module Rwm
12
12
  @no_cache = false
13
13
  @buffered = false
14
14
  @concurrency = nil
15
+ @dry_run = false
16
+ @base_branch = nil
15
17
  parse_options
16
18
  end
17
19
 
@@ -19,7 +21,7 @@ module Rwm
19
21
  task = @argv.shift
20
22
 
21
23
  unless task
22
- $stderr.puts "Usage: rwm run <task> [<package>] [--affected] [--no-cache] [--buffered] [--concurrency N]"
24
+ $stderr.puts "Usage: rwm run <task> [<package>] [--affected] [--base REF] [--dry-run] [--no-cache] [--buffered] [--concurrency N]"
23
25
  return 1
24
26
  end
25
27
 
@@ -29,14 +31,9 @@ module Rwm
29
31
  graph = DependencyGraph.load(workspace)
30
32
 
31
33
  packages = if package_name
32
- pkg = workspace.find_package(package_name)
33
- unless pkg
34
- $stderr.puts "Unknown package: #{package_name}"
35
- return 1
36
- end
37
- [pkg]
34
+ [workspace.find_package(package_name)]
38
35
  elsif @affected_only
39
- detector = AffectedDetector.new(workspace, graph, committed_only: @committed_only)
36
+ detector = AffectedDetector.new(workspace, graph, committed_only: @committed_only, base_branch: @base_branch)
40
37
  affected = detector.affected_packages
41
38
  if affected.empty?
42
39
  puts "No affected packages. Nothing to run."
@@ -53,13 +50,15 @@ module Rwm
53
50
  return 0
54
51
  end
55
52
 
56
- # Filter to packages that have the requested rake task
57
- runnable = packages.select { |pkg| pkg.has_rake_task?(task) }
53
+ # Filter to packages with a Rakefile (skip those without one entirely)
54
+ runnable = packages.select(&:has_rakefile?)
58
55
  if runnable.empty?
59
- puts "No packages with a `#{task}` rake task found."
56
+ puts "No packages with a Rakefile found."
60
57
  return 0
61
58
  end
62
59
 
60
+ Rwm.debug("run: #{runnable.size} package(s) with Rakefiles")
61
+
63
62
  # Auto-detect cacheable tasks unless --no-cache
64
63
  cache = TaskCache.new(workspace, graph) unless @no_cache
65
64
  if cache
@@ -74,6 +73,12 @@ module Rwm
74
73
  return 0
75
74
  end
76
75
 
76
+ if @dry_run
77
+ puts "Dry run: would run `rake #{task}` on #{runnable.size} package(s):"
78
+ runnable.each { |pkg| puts " #{pkg.name}" }
79
+ return 0
80
+ end
81
+
77
82
  puts "Running `rake #{task}` across #{runnable.size} package(s)..."
78
83
  puts
79
84
 
@@ -86,19 +91,33 @@ module Rwm
86
91
  if cache
87
92
  runner.results.each do |result|
88
93
  next unless result.success
94
+ next if result.skipped
89
95
 
90
96
  pkg = workspace.find_package(result.package_name)
91
97
  cache.store(pkg, task) if cache.cacheable?(pkg, task)
92
98
  end
93
99
  end
94
100
 
101
+ passed, rest = runner.results.partition { |r| r.success && !r.skipped }
102
+ failed, skipped = rest.partition { |r| !r.success }
103
+ skipped_from_rest = runner.results.select(&:skipped)
104
+
105
+ total = runner.results.size
106
+ parts = []
107
+ parts << "#{passed.size} passed" unless passed.empty?
108
+ parts << "#{failed.size} failed" unless failed.empty?
109
+ parts << "#{skipped_from_rest.size} skipped" unless skipped_from_rest.empty?
110
+
95
111
  puts
96
- if runner.success?
97
- puts "All packages passed."
112
+ puts "#{total} package(s): #{parts.join(", ")}."
113
+
114
+ Rwm.debug("passed: #{passed.map(&:package_name).join(", ")}") unless passed.empty?
115
+ Rwm.debug("skipped (no matching task): #{skipped_from_rest.map(&:package_name).join(", ")}") unless skipped_from_rest.empty?
116
+
117
+ if failed.empty?
98
118
  0
99
119
  else
100
- failed = runner.failed_results
101
- $stderr.puts "#{failed.size} package(s) failed:"
120
+ $stderr.puts "Failed:"
102
121
  failed.each { |r| $stderr.puts " - #{r.package_name}" }
103
122
  1
104
123
  end
@@ -114,6 +133,12 @@ module Rwm
114
133
  opts.on("--committed", "Only consider committed changes (with --affected)") do
115
134
  @committed_only = true
116
135
  end
136
+ opts.on("--base REF", "Compare against REF instead of auto-detected base branch (with --affected)") do |ref|
137
+ @base_branch = ref
138
+ end
139
+ opts.on("--dry-run", "Show what would run without executing") do
140
+ @dry_run = true
141
+ end
117
142
  opts.on("--no-cache", "Bypass task caching even for cacheable tasks") do
118
143
  @no_cache = true
119
144
  end
@@ -61,7 +61,7 @@ module Rwm
61
61
  def topological_order
62
62
  tsort
63
63
  rescue TSort::Cyclic => e
64
- raise CycleError, [e.message]
64
+ raise CycleError, [[e.message]]
65
65
  end
66
66
 
67
67
  # Group packages into execution levels — packages at the same level
@@ -79,7 +79,7 @@ module Rwm
79
79
  dependencies(name).all? { |dep| placed.include?(dep) }
80
80
  end
81
81
 
82
- raise CycleError, ["Unable to resolve execution levels — possible cycle"] if level.empty?
82
+ raise CycleError, [["Unable to resolve execution levels — possible cycle"]] if level.empty?
83
83
 
84
84
  levels << level.sort
85
85
  remaining -= level
@@ -93,13 +93,16 @@ module Rwm
93
93
  def self.load(workspace)
94
94
  path = workspace.graph_path
95
95
  unless File.exist?(path)
96
+ Rwm.debug("graph: no cached graph found, building from scratch")
96
97
  return build_and_save(workspace)
97
98
  end
98
99
 
99
100
  if stale?(path, workspace.packages)
101
+ Rwm.debug("graph: cached graph is stale, rebuilding")
100
102
  return build_and_save(workspace)
101
103
  end
102
104
 
105
+ Rwm.debug("graph: loading from cache at #{path}")
103
106
  data = JSON.parse(File.read(path))
104
107
  graph = new
105
108
 
@@ -158,24 +161,7 @@ module Rwm
158
161
  File.write(path, JSON.pretty_generate(to_json_data) + "\n")
159
162
  end
160
163
 
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)
164
+ def to_dot
179
165
  lines = []
180
166
  lines << "digraph rwm {"
181
167
  lines << " rankdir=LR;"
@@ -195,7 +181,7 @@ module Rwm
195
181
  lines.join("\n") + "\n"
196
182
  end
197
183
 
198
- def to_mermaid(workspace_root)
184
+ def to_mermaid
199
185
  lines = []
200
186
  lines << "graph LR"
201
187
 
data/lib/rwm/gemfile.rb CHANGED
@@ -13,8 +13,15 @@
13
13
 
14
14
  require "bundler"
15
15
  require "open3"
16
+ require "set"
16
17
 
17
18
  module Rwm
19
+ @resolved_libs = Set.new
20
+
21
+ def self.resolved_libs
22
+ @resolved_libs
23
+ end
24
+
18
25
  module GemfileDsl
19
26
  def rwm_workspace_root
20
27
  @rwm_workspace_root ||= begin
@@ -26,8 +33,41 @@ module Rwm
26
33
  end
27
34
 
28
35
  def rwm_lib(name, **opts)
29
- path = File.join(rwm_workspace_root, "libs", name.to_s)
30
- gem(name.to_s, **opts, path: path)
36
+ name = name.to_s
37
+ @rwm_resolved ||= Set.new
38
+ return if @rwm_resolved.include?(name)
39
+
40
+ @rwm_resolved.add(name)
41
+ Rwm.resolved_libs.add(name)
42
+
43
+ path = File.join(rwm_workspace_root, "libs", name)
44
+ gem(name, **opts, path: path)
45
+
46
+ # Resolve transitive workspace deps from the target lib's Gemfile
47
+ target_gemfile = File.join(path, "Gemfile")
48
+ return unless File.exist?(target_gemfile)
49
+
50
+ scan_transitive_deps(target_gemfile).each { |dep_name| rwm_lib(dep_name) }
51
+ end
52
+
53
+ private
54
+
55
+ def scan_transitive_deps(gemfile_path)
56
+ sandbox = Bundler::Dsl.new
57
+ sandbox.eval_gemfile(gemfile_path)
58
+
59
+ libs_prefix = File.join(rwm_workspace_root, "libs") + "/"
60
+ gemfile_dir = File.dirname(gemfile_path)
61
+
62
+ sandbox.dependencies.each_with_object([]) do |dep, result|
63
+ source = dep.source
64
+ next unless source.is_a?(Bundler::Source::Path)
65
+
66
+ dep_path = File.expand_path(source.path.to_s, gemfile_dir)
67
+ next unless dep_path.start_with?(libs_prefix)
68
+
69
+ result << File.basename(dep_path)
70
+ end
31
71
  end
32
72
 
33
73
  end
data/lib/rwm/package.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
- require "open3"
5
4
 
6
5
  module Rwm
7
6
  class Package
@@ -25,31 +24,14 @@ module Rwm
25
24
  File.exist?(File.join(path, "Rakefile"))
26
25
  end
27
26
 
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
27
  def gemfile_path
38
28
  File.join(path, "Gemfile")
39
29
  end
40
30
 
41
- def gemspec_path
42
- Dir.glob(File.join(path, "*.gemspec")).first
43
- end
44
-
45
31
  def relative_path(workspace_root)
46
32
  Pathname.new(path).relative_path_from(Pathname.new(workspace_root)).to_s
47
33
  end
48
34
 
49
- def to_s
50
- "#{name} (#{type})"
51
- end
52
-
53
35
  def ==(other)
54
36
  other.is_a?(Package) && name == other.name && path == other.path
55
37
  end
data/lib/rwm/rails.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails integration for RWM workspaces.
4
+ #
5
+ # Usage in config/application.rb:
6
+ #
7
+ # require_relative "boot"
8
+ # require "rwm/rails"
9
+ # Rwm.require_libs
10
+ # require "rails"
11
+
12
+ require "rwm/gemfile"
13
+
14
+ module Rwm
15
+ def self.require_libs
16
+ self.resolved_libs.each { |name| require name }
17
+ end
18
+ end
@@ -7,6 +7,17 @@ require "open3"
7
7
 
8
8
  module Rwm
9
9
  class TaskCache
10
+ def self.clean(workspace, package_name: nil)
11
+ cache_dir = File.join(workspace.root, ".rwm", "cache")
12
+ return unless Dir.exist?(cache_dir)
13
+
14
+ if package_name
15
+ Dir.glob(File.join(cache_dir, "#{package_name}-*")).each { |f| File.delete(f) }
16
+ else
17
+ Dir.glob(File.join(cache_dir, "*")).each { |f| File.delete(f) }
18
+ end
19
+ end
20
+
10
21
  def initialize(workspace, graph)
11
22
  @workspace = workspace
12
23
  @graph = graph
@@ -25,20 +36,33 @@ module Rwm
25
36
  # Also verifies declared outputs exist (if any).
26
37
  def cached?(package, task)
27
38
  stored = read_stored_hash(package, task)
28
- return false unless stored
29
- return false unless stored == content_hash(package)
39
+ unless stored
40
+ Rwm.debug("cache miss: #{package.name}:#{task} (no stored hash)")
41
+ return false
42
+ end
43
+
44
+ current = content_hash(package)
45
+ unless stored == current
46
+ Rwm.debug("cache miss: #{package.name}:#{task} (hash changed)")
47
+ return false
48
+ end
30
49
 
31
50
  # If outputs are declared, they must exist
32
51
  decl = cache_declarations(package)[task]
33
52
  if decl && decl["output"]
34
- return false unless outputs_exist?(package, decl["output"])
53
+ unless outputs_exist?(package, decl["output"])
54
+ Rwm.debug("cache miss: #{package.name}:#{task} (outputs missing)")
55
+ return false
56
+ end
35
57
  end
36
58
 
59
+ Rwm.debug("cache hit: #{package.name}:#{task}")
37
60
  true
38
61
  end
39
62
 
40
63
  # Store the current content hash after a successful task run
41
64
  def store(package, task)
65
+ Rwm.debug("cache store: #{package.name}:#{task}")
42
66
  FileUtils.mkdir_p(@cache_dir)
43
67
  path = cache_file(package, task)
44
68
  File.write(path, content_hash(package))
@@ -78,6 +102,7 @@ module Rwm
78
102
  def cache_declarations(package)
79
103
  return @cache_declarations[package.name] if @cache_declarations.key?(package.name)
80
104
 
105
+ Rwm.debug("cache declarations: discovering for #{package.name}")
81
106
  output, _, status = Open3.capture3("bundle", "exec", "rake", "rwm:cache_config", chdir: package.path)
82
107
  @cache_declarations[package.name] = if status.success? && !output.strip.empty?
83
108
  JSON.parse(output.strip)
@@ -91,13 +116,19 @@ module Rwm
91
116
  private
92
117
 
93
118
  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
119
+ # Tracked files + untracked-but-not-ignored files (null-delimited for safe filenames)
120
+ output, _, status = Open3.capture3(
121
+ "git", "ls-files", "-z", "--cached", "--others", "--exclude-standard",
122
+ chdir: package.path
123
+ )
124
+ return [] unless status.success?
125
+
126
+ files = output.split("\0")
127
+ .reject(&:empty?)
128
+ .sort
129
+ .map { |f| File.join(package.path, f) }
130
+ Rwm.debug("source_files: #{package.name} → #{files.size} file(s)")
131
+ files
101
132
  end
102
133
 
103
134
  def cache_file(package, task)
@@ -5,7 +5,9 @@ require "etc"
5
5
 
6
6
  module Rwm
7
7
  class TaskRunner
8
- Result = Struct.new(:package_name, :task, :success, :output, keyword_init: true)
8
+ Result = Struct.new(:package_name, :task, :success, :output, :skipped, keyword_init: true)
9
+
10
+ NO_TASK_PATTERN = /Don't know how to build task/
9
11
 
10
12
  attr_reader :results
11
13
 
@@ -28,16 +30,33 @@ module Rwm
28
30
  completed = Set.new
29
31
  skipped = Set.new
30
32
  running = {}
33
+ @interrupted = false
31
34
 
32
35
  mutex = Mutex.new
33
36
  condition = ConditionVariable.new
34
37
 
35
- until pending.empty? && running.empty?
38
+ previous_trap = Signal.trap("INT") do
39
+ @interrupted = true
40
+ # Cannot use mutex inside trap context — just kill threads directly.
41
+ # Thread#kill is safe to call from trap context.
42
+ running.each_value { |t| t.kill rescue nil }
43
+ end
44
+
45
+ done = false
46
+ until done
47
+ break if @interrupted
48
+
36
49
  mutex.synchronize do
50
+ if pending.empty? && running.empty?
51
+ done = true
52
+ next
53
+ end
54
+
37
55
  ready = pending.select { |pkg| ready?(pkg, package_names, completed) }
38
56
 
39
57
  ready.each do |pkg|
40
58
  break if running.size >= @concurrency
59
+ break if @interrupted
41
60
 
42
61
  pending.delete(pkg)
43
62
  running[pkg.name] = Thread.new do
@@ -73,7 +92,11 @@ module Rwm
73
92
  end
74
93
  end
75
94
 
95
+ raise Interrupt, "Interrupted by Ctrl+C" if @interrupted
96
+
76
97
  @results
98
+ ensure
99
+ Signal.trap("INT", previous_trap || "DEFAULT")
77
100
  end
78
101
 
79
102
  # Run a rake task in each package
@@ -105,10 +128,23 @@ module Rwm
105
128
  def run_single(pkg, &command_proc)
106
129
  cmd = command_proc.call(pkg)
107
130
  prefix = "[#{pkg.name}]"
131
+ Rwm.debug("running: #{cmd.join(' ')} in #{pkg.path}")
108
132
 
109
133
  stdout, stderr, status = Open3.capture3(*cmd, chdir: pkg.path)
110
134
  output = format_output(prefix, stdout, stderr)
111
135
 
136
+ # Detect "task not found" and treat as skipped, not failed
137
+ if !status.success? && stderr.match?(NO_TASK_PATTERN)
138
+ Rwm.debug("#{pkg.name}: task not found, skipping")
139
+ return Result.new(
140
+ package_name: pkg.name,
141
+ task: cmd.join(" "),
142
+ success: true,
143
+ output: "",
144
+ skipped: true
145
+ )
146
+ end
147
+
112
148
  if @buffered
113
149
  print_buffered_output(pkg.name, output, status.success?)
114
150
  else
data/lib/rwm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rwm
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/rwm/workspace.rb CHANGED
@@ -33,14 +33,6 @@ module Rwm
33
33
  File.join(rwm_dir, GRAPH_FILE)
34
34
  end
35
35
 
36
- def libs_dir
37
- File.join(root, "libs")
38
- end
39
-
40
- def apps_dir
41
- File.join(root, "apps")
42
- end
43
-
44
36
  # Discover all packages by scanning libs/ and apps/ for directories with a Gemfile
45
37
  def packages
46
38
  @packages ||= discover_packages
data/lib/rwm.rb CHANGED
@@ -4,6 +4,20 @@ require_relative "rwm/version"
4
4
  require_relative "rwm/errors"
5
5
 
6
6
  module Rwm
7
+ @verbose = false
8
+
9
+ def self.verbose?
10
+ @verbose
11
+ end
12
+
13
+ def self.verbose=(value)
14
+ @verbose = value
15
+ end
16
+
17
+ def self.debug(msg)
18
+ $stderr.puts "[rwm debug] #{msg}" if @verbose
19
+ end
20
+
7
21
  autoload :Workspace, "rwm/workspace"
8
22
  autoload :Package, "rwm/package"
9
23
  autoload :GemfileParser, "rwm/gemfile_parser"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_workspace_manager
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Siddharth Bhatt
@@ -20,11 +20,14 @@ files:
20
20
  - LICENSE.txt
21
21
  - README.md
22
22
  - bin/rwm
23
+ - completions/rwm.bash
24
+ - completions/rwm.zsh
23
25
  - lib/rwm.rb
24
26
  - lib/rwm/affected_detector.rb
25
27
  - lib/rwm/cli.rb
26
28
  - lib/rwm/commands/affected.rb
27
29
  - lib/rwm/commands/bootstrap.rb
30
+ - lib/rwm/commands/cache.rb
28
31
  - lib/rwm/commands/check.rb
29
32
  - lib/rwm/commands/graph.rb
30
33
  - lib/rwm/commands/info.rb
@@ -40,6 +43,7 @@ files:
40
43
  - lib/rwm/git_hooks.rb
41
44
  - lib/rwm/overcommit.rb
42
45
  - lib/rwm/package.rb
46
+ - lib/rwm/rails.rb
43
47
  - lib/rwm/rake.rb
44
48
  - lib/rwm/task_cache.rb
45
49
  - lib/rwm/task_runner.rb
@@ -50,8 +54,8 @@ homepage: https://github.com/sidbhatt11/ruby-workspace-manager
50
54
  licenses:
51
55
  - MIT
52
56
  metadata:
53
- homepage_uri: https://github.com/sidbhatt11/ruby-workspace-manager
54
57
  source_code_uri: https://github.com/sidbhatt11/ruby-workspace-manager
58
+ changelog_uri: https://github.com/sidbhatt11/ruby-workspace-manager/blob/main/CHANGELOG.md
55
59
  rdoc_options: []
56
60
  require_paths:
57
61
  - lib