Skip to content

Commit b7a96e5

Browse files
committed
Add --recursive option for transitive dependencies
1 parent c20e885 commit b7a96e5

File tree

10 files changed

+455
-9
lines changed

10 files changed

+455
-9
lines changed

workspace/CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1919
Example use cases:
2020

2121
- Run tests on affected projects but always include critical services:
22+
2223
```bash
2324
mix workspace.run -t test --affected --include auth --include payment
2425
```
2526

2627
- Get all dependencies of a project plus the project itself:
28+
2729
```bash
2830
mix workspace.list --dependent my_api --include my_api --format json
2931
```
3032

3133
Note: `--exclude` always has the highest priority - excluded projects cannot
3234
be re-included with `--include`.
3335

36+
* Add `--recursive` option for transitive dependency filtering
37+
38+
The `--recursive` option enables deep dependency traversal when used with
39+
`--dependency` or `--dependent` flags, allowing you to work with all
40+
transitive dependencies instead of just first-level ones.
41+
42+
Available in `workspace.run` and `workspace.list` tasks.
43+
44+
Example use cases:
45+
46+
- Get all projects that transitively depend on a shared library:
47+
48+
```bash
49+
mix workspace.list --dependency shared_utils --recursive
50+
```
51+
52+
- Run tests on all transitive dependencies of an API service:
53+
54+
```bash
55+
mix workspace.run -t test --dependent my_api --recursive
56+
```
57+
58+
- Find all projects affected by changes to a core dependency:
59+
60+
```bash
61+
mix workspace.list --dependency core_lib --recursive --format json
62+
```
63+
64+
By default (without `--recursive`), `--dependency` and `--dependent` only
65+
consider direct (first-level) dependencies to maintain backward compatibility.
66+
3467
## [v0.3.1](https://github.com/sportradar/elixir-workspace/tree/workspace/v0.3.1) (2025-10-24)
3568

3669
### Fixed

workspace/lib/mix/tasks/workspace.list.ex

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ defmodule Mix.Tasks.Workspace.List do
5959
:excluded_tags,
6060
:dependency,
6161
:dependent,
62+
:recursive,
6263
:show_status,
6364
:affected,
6465
:modified,
@@ -114,6 +115,15 @@ defmodule Mix.Tasks.Workspace.List do
114115
115116
$ mix workspace.list --dependent foo
116117
118+
By default, both `--dependency` and `--dependent` consider only direct (first-level)
119+
dependencies. You can use the `--recursive` flag to include all transitive dependencies:
120+
121+
# Get all projects that transitively depend on foo (direct and indirect)
122+
$ mix workspace.list --dependency foo --recursive
123+
124+
# Get all transitive dependencies of foo (direct and indirect)
125+
$ mix workspace.list --dependent foo --recursive
126+
117127
You can also filter by the project's maintainer. The search is case insensitive. The
118128
maintainers are expected to be defined under `package`:
119129
@@ -157,6 +167,8 @@ defmodule Mix.Tasks.Workspace.List do
157167
>
158168
> This creates a minimal workspace containing only the projects relevant to `my_api`,
159169
> making it easier to navigate and work with a specific subset of your monorepo.
170+
>
171+
> If you want to also include transitive dependencies, you can use the `--recursive` flag.
160172
161173
"""
162174
use Mix.Task

workspace/lib/mix/tasks/workspace.run.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ defmodule Mix.Tasks.Workspace.Run do
9595
:show_status,
9696
:paths,
9797
:dependency,
98-
:dependent
98+
:dependent,
99+
:recursive
99100
],
100101
opts
101102
)

workspace/lib/workspace/cli_options.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ defmodule Workspace.CliOptions do
6969
""",
7070
doc_section: :filtering
7171
],
72+
recursive: [
73+
type: :boolean,
74+
default: false,
75+
doc: """
76+
If set, when used with `--dependency` or `--dependent`, it will consider all transitive
77+
dependencies instead of just first-level ones.
78+
""",
79+
doc_section: :filtering
80+
],
7281
excluded_tags: [
7382
type: :string,
7483
multiple: true,

workspace/lib/workspace/filtering.ex

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ defmodule Workspace.Filtering do
3737
Only projects located under any of the provided path will be considered.
3838
* `:dependency` (`t:atom/0`) - keeps only projects that have the given dependency.
3939
* `:dependent` (`t:atom/0`) - keeps only dependencies of the given project.
40+
* `:recursive` (`t:boolean/0`) - if set, when used with `:dependency` or `:dependent`,
41+
it will consider all transitive dependencies instead of just first-level ones. Defaults to `false`.
4042
* `:affected` (`t:boolean/0`) - if set only the affected projects will be
4143
included and everything else will be skipped. Defaults to `false`.
4244
* `:modified` (`t:boolean/0`) - if set only the modified projects will be
@@ -86,6 +88,7 @@ defmodule Workspace.Filtering do
8688
tags: Enum.map(opts[:tags] || [], &maybe_to_tag/1),
8789
dependency: maybe_to_atom(opts[:dependency]),
8890
dependent: maybe_to_atom(opts[:dependent]),
91+
recursive: opts[:recursive] || false,
8992
paths: opts[:paths]
9093
]
9194

@@ -152,10 +155,10 @@ defmodule Workspace.Filtering do
152155
|> skip_if(workspace, fn project, _workspace -> not_affected?(project, opts[:affected]) end)
153156
|> skip_if(workspace, fn project, _workspace -> not_modified?(project, opts[:modified]) end)
154157
|> skip_if(workspace, fn project, workspace ->
155-
not_dependency?(workspace, project, opts[:dependency])
158+
not_dependency?(workspace, project, opts[:dependency], opts[:recursive])
156159
end)
157160
|> skip_if(workspace, fn project, workspace ->
158-
not_dependent?(workspace, project, opts[:dependent])
161+
not_dependent?(workspace, project, opts[:dependent], opts[:recursive])
159162
end)
160163
end
161164

@@ -203,13 +206,29 @@ defmodule Workspace.Filtering do
203206
defp not_modified?(_project, false), do: false
204207
defp not_modified?(project, true), do: not Workspace.Project.modified?(project)
205208

206-
defp not_dependency?(_workspace, _project, nil), do: false
209+
defp not_dependency?(_workspace, _project, nil, _recursive), do: false
207210

208-
defp not_dependency?(workspace, project, dependency),
209-
do: dependency not in Workspace.Graph.dependencies(workspace, project.app)
211+
defp not_dependency?(workspace, project, dependency, recursive) do
212+
deps =
213+
if recursive do
214+
Workspace.Graph.all_dependencies(workspace, project.app)
215+
else
216+
Workspace.Graph.dependencies(workspace, project.app)
217+
end
218+
219+
dependency not in deps
220+
end
210221

211-
defp not_dependent?(_workspace, _project, nil), do: false
222+
defp not_dependent?(_workspace, _project, nil, _recursive), do: false
212223

213-
defp not_dependent?(workspace, project, dependent),
214-
do: project.app not in Workspace.Graph.dependencies(workspace, dependent)
224+
defp not_dependent?(workspace, project, dependent, recursive) do
225+
deps =
226+
if recursive do
227+
Workspace.Graph.all_dependencies(workspace, dependent)
228+
else
229+
Workspace.Graph.dependencies(workspace, dependent)
230+
end
231+
232+
project.app not in deps
233+
end
215234
end

workspace/lib/workspace/graph.ex

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,33 @@ defmodule Workspace.Graph do
221221
|> Enum.map(& &1.app)
222222
end
223223

224+
@doc """
225+
Returns all transitive dependencies of the given `project`
226+
227+
This includes direct dependencies and all of their dependencies recursively.
228+
"""
229+
@spec all_dependencies(workspace :: Workspace.State.t(), project :: atom()) :: [atom()]
230+
def all_dependencies(workspace, project) do
231+
node = node_by_app(workspace.graph, project)
232+
233+
:digraph_utils.reachable_neighbours([node], workspace.graph)
234+
|> Enum.map(& &1.app)
235+
end
236+
237+
@doc """
238+
Returns all transitive dependents of the given `project`
239+
240+
This includes direct dependents (projects that depend on this project) and all
241+
projects that depend on those, recursively.
242+
"""
243+
@spec all_dependents(workspace :: Workspace.State.t(), project :: atom()) :: [atom()]
244+
def all_dependents(workspace, project) do
245+
node = node_by_app(workspace.graph, project)
246+
247+
:digraph_utils.reaching_neighbours([node], workspace.graph)
248+
|> Enum.map(& &1.app)
249+
end
250+
224251
@doc """
225252
Returns a subgraph around the given `project` and all nodes within the given `proximity`.
226253
"""

workspace/test/mix/tasks/workspace.list_test.exs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,68 @@ defmodule Mix.Tasks.Workspace.ListTest do
299299
)
300300
end
301301

302+
@tag :tmp_dir
303+
test "filtering by --dependency with --recursive", %{tmp_dir: tmp_dir} do
304+
Workspace.Test.with_workspace(
305+
tmp_dir,
306+
[],
307+
:default,
308+
fn ->
309+
# Without --recursive: only package_c and package_f have package_g as direct dependency
310+
# With --recursive: package_a also transitively depends on package_g (through package_b and package_c->package_f)
311+
expected = """
312+
Found 4 workspace projects matching the given options.
313+
* :package_a package_a/mix.exs :shared, area:core
314+
* :package_b package_b/mix.exs - a dummy project
315+
* :package_c package_c/mix.exs
316+
* :package_f package_f/mix.exs
317+
"""
318+
319+
assert capture_io(fn ->
320+
ListTask.run([
321+
"--workspace-path",
322+
tmp_dir,
323+
"--dependency",
324+
"package_g",
325+
"--recursive"
326+
])
327+
end) == expected
328+
end
329+
)
330+
end
331+
332+
@tag :tmp_dir
333+
test "filtering by --dependent with --recursive", %{tmp_dir: tmp_dir} do
334+
Workspace.Test.with_workspace(
335+
tmp_dir,
336+
[],
337+
:default,
338+
fn ->
339+
# Without --recursive: package_a depends directly on package_b, package_c, package_d
340+
# With --recursive: also includes transitive dependencies like package_e, package_f, package_g
341+
expected = """
342+
Found 6 workspace projects matching the given options.
343+
* :package_b package_b/mix.exs - a dummy project
344+
* :package_c package_c/mix.exs
345+
* :package_d package_d/mix.exs
346+
* :package_e package_e/mix.exs
347+
* :package_f package_f/mix.exs
348+
* :package_g package_g/mix.exs
349+
"""
350+
351+
assert capture_io(fn ->
352+
ListTask.run([
353+
"--workspace-path",
354+
tmp_dir,
355+
"--dependent",
356+
"package_a",
357+
"--recursive"
358+
])
359+
end) == expected
360+
end
361+
)
362+
end
363+
302364
@tag :tmp_dir
303365
test "filtering by --path", %{tmp_dir: tmp_dir} do
304366
Workspace.Test.with_workspace(

workspace/test/mix/tasks/workspace.run_test.exs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,117 @@ defmodule Mix.Tasks.Workspace.RunTest do
890890
)
891891
end
892892

893+
describe "filtering with --recursive option" do
894+
@tag :tmp_dir
895+
test "with --dependency and --recursive", %{tmp_dir: tmp_dir} do
896+
Workspace.Test.with_workspace(
897+
tmp_dir,
898+
[],
899+
:default,
900+
fn ->
901+
# Without --recursive: only package_b and package_f have package_g as direct dependency
902+
# With --recursive: package_a also transitively depends on package_g
903+
args = [
904+
"--workspace-path",
905+
tmp_dir,
906+
"--dependency",
907+
"package_g",
908+
"--recursive",
909+
"--dry-run" | @default_run_task
910+
]
911+
912+
captured =
913+
capture_io(fn ->
914+
RunTask.run(args)
915+
end)
916+
917+
assert_cli_output_match(captured, [
918+
"Running task in 4 workspace projects",
919+
"==> :package_a - mix format --check-formatted mix.exs",
920+
"==> :package_b - mix format --check-formatted mix.exs",
921+
"==> :package_c - mix format --check-formatted mix.exs",
922+
"==> :package_f - mix format --check-formatted mix.exs"
923+
])
924+
end
925+
)
926+
end
927+
928+
@tag :tmp_dir
929+
test "with --dependent and --recursive", %{tmp_dir: tmp_dir} do
930+
Workspace.Test.with_workspace(
931+
tmp_dir,
932+
[],
933+
:default,
934+
fn ->
935+
# Without --recursive: package_a depends directly on package_b, package_c, package_d
936+
# With --recursive: also includes transitive dependencies
937+
args = [
938+
"--workspace-path",
939+
tmp_dir,
940+
"--dependent",
941+
"package_a",
942+
"--recursive",
943+
"--dry-run" | @default_run_task
944+
]
945+
946+
captured =
947+
capture_io(fn ->
948+
RunTask.run(args)
949+
end)
950+
951+
assert_cli_output_match(captured, [
952+
"Running task in 6 workspace projects",
953+
"==> :package_b - mix format --check-formatted mix.exs",
954+
"==> :package_c - mix format --check-formatted mix.exs",
955+
"==> :package_d - mix format --check-formatted mix.exs",
956+
"==> :package_e - mix format --check-formatted mix.exs",
957+
"==> :package_f - mix format --check-formatted mix.exs",
958+
"==> :package_g - mix format --check-formatted mix.exs"
959+
])
960+
961+
# Verify package_a itself is not included
962+
refute String.contains?(captured, "==> :package_a")
963+
end
964+
)
965+
end
966+
967+
@tag :tmp_dir
968+
test "with --dependency without --recursive only shows direct dependencies", %{
969+
tmp_dir: tmp_dir
970+
} do
971+
Workspace.Test.with_workspace(
972+
tmp_dir,
973+
[],
974+
:default,
975+
fn ->
976+
# Without --recursive: only direct dependencies (package_b and package_f)
977+
args = [
978+
"--workspace-path",
979+
tmp_dir,
980+
"--dependency",
981+
"package_g",
982+
"--dry-run" | @default_run_task
983+
]
984+
985+
captured =
986+
capture_io(fn ->
987+
RunTask.run(args)
988+
end)
989+
990+
assert_cli_output_match(captured, [
991+
"Running task in 2 workspace projects",
992+
"==> :package_b - mix format --check-formatted mix.exs",
993+
"==> :package_f - mix format --check-formatted mix.exs"
994+
])
995+
996+
# Verify transitive dependents (package_a, package_c) are not included
997+
refute String.contains?(captured, "==> :package_a")
998+
refute String.contains?(captured, "==> :package_c")
999+
end
1000+
)
1001+
end
1002+
end
1003+
8931004
defp maybe_shell do
8941005
if System.version() |> String.starts_with?("1.19") do
8951006
"--shell"

0 commit comments

Comments
 (0)