Skip to content

Commit 3b7e815

Browse files
committed
Add affected_by option for explicit project dependencies
Enables projects to declare dependencies on files outside their directory structure, addressing common monorepo scenarios where projects depend on shared resources that cannot be detected through mix dependencies. Use cases include: - Shared configuration files across multiple projects - Documentation changes that affect project builds - Cross-language dependencies (e.g., Rust NIFs depending on Rust crates) - Build artifacts or generated files from other projects The affected_by paths are resolved relative to each project's root, support wildcard patterns for flexible matching, and properly propagate affected status through the dependency graph to ensure all upstream projects are correctly marked when their dependencies change.
1 parent c82a6d1 commit 3b7e815

File tree

4 files changed

+248
-16
lines changed

4 files changed

+248
-16
lines changed

workspace/lib/workspace.ex

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,12 @@ defmodule Workspace do
4444
```
4545
my_workspace
4646
├── apps
47-
│   ├── api # an API app
47+
│   ├── api # an API app
4848
│   └── ui # the UI project
4949
├── mix.exs # this is the workspace root definition
5050
├── .workspace.exs # the workspace config
5151
└── packages # various reusable packages under packages
52-
├── package_a
52+
├── package_a
5353
├── package_b
5454
└── package_c
5555
```
@@ -91,7 +91,7 @@ defmodule Workspace do
9191
> an exception will be raised.
9292
>
9393
> For example the following workspace:
94-
>
94+
>
9595
> ```
9696
> my_workspace
9797
> ├── apps
@@ -123,7 +123,7 @@ defmodule Workspace do
123123
each project with graph metadata.
124124
125125
> #### Inspecting the graph {: .tip}
126-
>
126+
>
127127
> You can use the `workspace.graph` command in order to see the
128128
> graph of the given `workspace`. For example:
129129
>
@@ -198,7 +198,7 @@ defmodule Workspace do
198198
* `:modified` - returns only the modified projects, e.g. projects for which
199199
the code has changed
200200
* `:affected` - returns all affected projects. Affected projects are the
201-
modified ones plus the
201+
modified ones plus the
202202
203203
`:modified` and `:affected` can be combined with the global filtering options.
204204
@@ -213,7 +213,7 @@ defmodule Workspace do
213213
> should select a specific top level app when building the project. This will
214214
> ignore all other irrelevant apps.
215215
> - When changing a specific set of projects, you should use `:modified` for
216-
> formatting the code since everything else is not affected.
216+
> formatting the code since everything else is not affected.
217217
> - Similarly for testing you should use the `:affected` filtering since a
218218
> change on a project may affect all parents.
219219
> - It is advised to have generic CI pipelines on master/main branches that
@@ -258,7 +258,7 @@ defmodule Workspace do
258258
> classDef affected fill:#FA6,color:#FFF;
259259
> classDef modified fill:#F33,color:#FFF;
260260
> ```
261-
>
261+
>
262262
> Modified projects are indicated with red colors, and affected projects are
263263
> highlighted with orange color.
264264
>
@@ -275,6 +275,55 @@ defmodule Workspace do
275275
> mix workspace.run -t test --affected
276276
> ```
277277
278+
### Explicit dependencies
279+
280+
Sometimes you may want to specify explicit dependencies between projects and files
281+
that cannot be deduced by the mix dependencies. For example you may have a Rust NIF
282+
project which uses a Rust crate also present in your monorepo, or you may require
283+
some `*.exs` files that are shared across the workspace.
284+
285+
In such cases you can use the `:affected_by` option in the project's workspace config
286+
to explicitly define those dependencies. If any of the files defined there
287+
is changed, then the project is considered affected.
288+
289+
When workspace status is updated, the system checks if any changed files match
290+
the patterns defined in `affected_by`. If a match is found, the project is marked
291+
as `:affected` and will be included in affected project lists.
292+
293+
> #### Path Resolution & Patterns {: .info}
294+
>
295+
> All paths in `:affected_by` are resolved relative to the **workspace's root directory**.
296+
> This means you can reference files outside your project using relative paths like
297+
> `../shared/config.ex` or `../../docs/README.md`.
298+
>
299+
> The `:affected_by` option supports several path patterns:
300+
>
301+
> - **Exact file paths**: `"../shared/config.ex"` - matches only this specific file
302+
> - **Wildcard patterns**:
303+
> - `"../shared/*.ex"` - matches any `.ex` file in the `../shared/` directory
304+
> - `"../docs/**/*.md"` - matches any `.md` file in the `../docs/` directory including
305+
> nested directories
306+
> - **Directory paths**: `"../shared/"` - matches any file within the `../shared/` directory
307+
>
308+
> For example:
309+
>
310+
> ```elixir
311+
> # In a project's mix.exs
312+
> def project do
313+
> [
314+
> # ... other config
315+
> workspace: [
316+
> affected_by: [
317+
> "../shared/config.ex", # Specific file
318+
> "../shared/*.ex", # All .ex files in shared directory
319+
> "../../docs/**/*.md", # All markdown files in docs including nested directories
320+
> "../shared/", # Any file in shared directory
321+
> ]
322+
> ]
323+
> ]
324+
> end
325+
> ```
326+
278327
## Environment variables
279328
280329
The following environment variables are supported:
@@ -575,7 +624,7 @@ defmodule Workspace do
575624
end
576625

577626
@doc """
578-
Returns `true` if the given `app` is a `workspace` project, `false` otherwise.
627+
Returns `true` if the given `app` is a `workspace` project, `false` otherwise.
579628
"""
580629
@spec project?(workspace :: Workspace.State.t(), app :: atom()) :: boolean()
581630
def project?(workspace, app) when is_struct(workspace, Workspace.State) and is_atom(app),

workspace/lib/workspace/project.ex

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,25 @@ defmodule Workspace.Project do
1313
""",
1414
required: false,
1515
default: []
16+
],
17+
affected_by: [
18+
type: {:list, :string},
19+
doc: """
20+
List of file paths that if changed will mark this project as affected.
21+
This is useful when a project depends on files outside of its root path.
22+
Paths are relative to the project's root directory and support wildcards.
23+
24+
```elixir
25+
affected_by: ["../shared/config.ex", "../../docs/*.md", "config/*.json"]
26+
```
27+
28+
Supported wildcards:
29+
- `*` matches any characters except path separators
30+
- `?` matches a single character
31+
- Directory paths will match any file within that directory
32+
""",
33+
required: false,
34+
default: []
1635
]
1736
]
1837

@@ -56,7 +75,8 @@ defmodule Workspace.Project do
5675
status: :undefined | :unaffected | :modified | :affected,
5776
root?: nil | boolean(),
5877
changes: nil | [{Path.t(), Workspace.Git.change_type()}],
59-
tags: [tag()]
78+
tags: [tag()],
79+
affected_by: [String.t()]
6080
}
6181

6282
@enforce_keys [:app, :module, :config, :mix_path, :path, :workspace_path]
@@ -70,7 +90,8 @@ defmodule Workspace.Project do
7090
status: :undefined,
7191
root?: nil,
7292
changes: nil,
73-
tags: []
93+
tags: [],
94+
affected_by: []
7495

7596
@doc """
7697
Creates a new project for the given project path.
@@ -109,15 +130,20 @@ defmodule Workspace.Project do
109130
) :: t()
110131
def new(workspace_path, mix_path, module, config) do
111132
workspace_config = validate_workspace_project_config!(config)
133+
project_path = Path.dirname(mix_path)
134+
135+
# Expand affected_by paths relative to the project path
136+
affected_by = Enum.map(workspace_config[:affected_by], &Path.expand(&1, project_path))
112137

113138
%__MODULE__{
114139
app: config[:app],
115140
module: module,
116141
config: config,
117142
mix_path: mix_path,
118-
path: Path.dirname(mix_path),
143+
path: project_path,
119144
workspace_path: workspace_path,
120-
tags: workspace_config[:tags]
145+
tags: workspace_config[:tags],
146+
affected_by: affected_by
121147
}
122148
end
123149

workspace/lib/workspace/status.ex

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,16 @@ defmodule Workspace.Status do
5454
end)
5555
end)
5656

57-
# Affected projects
57+
# Check for projects affected by their affected_by paths
58+
affected_by_changes = check_affected_by_paths(workspace, changes)
59+
60+
# Affected projects (from dependencies + affected_by paths)
5861
modified = Enum.map(modifications, fn {project, _changes} -> project end)
59-
affected = Workspace.Graph.affected(workspace, modified)
62+
63+
affected_by_modified =
64+
Enum.map(affected_by_changes, fn {project, _changes} -> project end)
65+
66+
affected = Workspace.Graph.affected(workspace, modified ++ affected_by_modified)
6067

6168
projects =
6269
Enum.reduce(affected, projects, fn project, workspace_projects ->
@@ -167,4 +174,48 @@ defmodule Workspace.Status do
167174
|> Enum.map(fn {name, _project} -> name end)
168175
|> Enum.sort()
169176
end
177+
178+
defp check_affected_by_paths(workspace, changes) do
179+
base_path = workspace.git_root_path || workspace.workspace_path
180+
181+
# Expand all changed files to full paths
182+
changed_files =
183+
changes
184+
|> Map.values()
185+
|> List.flatten()
186+
|> Enum.map(fn {changed_file, type} ->
187+
{Path.expand(changed_file, base_path), type}
188+
end)
189+
190+
# Check each project's affected_by paths
191+
workspace.projects
192+
|> Enum.filter(fn {_name, project} -> length(project.affected_by) > 0 end)
193+
|> Enum.reduce(%{}, fn {name, project}, acc ->
194+
affected_files =
195+
project.affected_by
196+
|> Enum.filter(fn affected_path ->
197+
# affected_path is already expanded in project creation
198+
# Check if any changed file matches this affected_by path
199+
Enum.any?(changed_files, fn {full_changed_path, _type} ->
200+
matches_affected_by_path?(full_changed_path, affected_path)
201+
end)
202+
end)
203+
204+
if length(affected_files) > 0 do
205+
# Create file_info entries for the affected files
206+
file_infos = Enum.map(affected_files, fn file -> {file, :modified} end)
207+
Map.put(acc, name, file_infos)
208+
else
209+
acc
210+
end
211+
end)
212+
end
213+
214+
defp matches_affected_by_path?(changed_path, affected_path) do
215+
cond do
216+
Workspace.Utils.Path.parent_dir?(affected_path, changed_path) -> true
217+
changed_path in Path.wildcard(affected_path) -> true
218+
true -> false
219+
end
220+
end
170221
end

workspace/test/workspace/status_test.exs

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,15 +237,15 @@ defmodule Workspace.StatusTest do
237237

238238
Workspace.Test.commit_changes(tmp_dir)
239239

240-
# modify both workspaces
240+
# modify both workspaces
241241
Workspace.Test.modify_project(workspace_path, "packages/foo")
242242
Workspace.Test.modify_project(another_workspace_path, "packages/bar")
243243

244244
workspace = Workspace.new!(workspace_path)
245245
another_workspace = Workspace.new!(another_workspace_path)
246246

247247
# the changed files should include the changed project, with paths relative to
248-
# the git root, the changed file of the other workspace should be under `nil`
248+
# the git root, the changed file of the other workspace should be under `nil`
249249
assert Workspace.Status.changed(workspace) == %{
250250
nil: [{"nested/another_workspace/packages/bar/lib/file.ex", :untracked}],
251251
foo: [{"workspace/packages/foo/lib/file.ex", :untracked}]
@@ -265,4 +265,110 @@ defmodule Workspace.StatusTest do
265265
end)
266266
end
267267
end
268+
269+
describe "affected_by paths" do
270+
@tag :tmp_dir
271+
test "marks project as affected when affected_by files change", %{tmp_dir: tmp_dir} do
272+
Workspace.Test.with_workspace(
273+
tmp_dir,
274+
[],
275+
[
276+
{:package_a, "package_a", [workspace: [affected_by: ["../shared/config.ex"]]]},
277+
{:package_b, "package_b", [workspace: [affected_by: ["../docs/*.md"]]]}
278+
],
279+
fn ->
280+
# Create shared files
281+
shared_dir = Path.join(tmp_dir, "shared")
282+
File.mkdir_p!(shared_dir)
283+
File.write!(Path.join(shared_dir, "config.ex"), "# config")
284+
285+
workspace = Workspace.new!(tmp_dir)
286+
refute workspace.status_updated?
287+
288+
# Modify shared config file
289+
File.write!(Path.join(shared_dir, "config.ex"), "# updated config")
290+
291+
workspace = Workspace.Status.update(workspace, [])
292+
assert workspace.status_updated?
293+
294+
# package_a should be affected due to shared/config.ex change
295+
assert workspace.projects[:package_a].status == :affected
296+
# package_b should be unaffected
297+
assert workspace.projects[:package_b].status == :undefined
298+
299+
docs_dir = Path.join(tmp_dir, "docs")
300+
File.mkdir_p!(docs_dir)
301+
File.write!(Path.join(docs_dir, "README.md"), "# docs")
302+
303+
workspace = Workspace.Status.update(workspace, force: true)
304+
assert workspace.status_updated?
305+
306+
# package_b should be affected due to docs/*.md change
307+
assert workspace.projects[:package_b].status == :affected
308+
end,
309+
git: true
310+
)
311+
end
312+
313+
@tag :tmp_dir
314+
test "supports wildcard patterns in affected_by", %{tmp_dir: tmp_dir} do
315+
Workspace.Test.with_workspace(
316+
tmp_dir,
317+
[],
318+
[
319+
{:package_a, "package_a", [workspace: [affected_by: ["../shared/*.ex"]]]}
320+
],
321+
fn ->
322+
# Create shared files
323+
shared_dir = Path.join(tmp_dir, "shared")
324+
File.mkdir_p!(shared_dir)
325+
326+
File.write!(Path.join(shared_dir, "config.txt"), "# config")
327+
328+
workspace = Workspace.new!(tmp_dir)
329+
refute workspace.status_updated?
330+
331+
# should not be affected by a *.txt change
332+
assert workspace.projects[:package_a].status == :undefined
333+
334+
# Modify .ex file in shared (should match)
335+
File.write!(Path.join(shared_dir, "utils.ex"), "# updated utils")
336+
337+
workspace = Workspace.Status.update(workspace, force: true)
338+
assert workspace.status_updated?
339+
340+
# package_a should be affected
341+
assert workspace.projects[:package_a].status == :affected
342+
end,
343+
git: true
344+
)
345+
end
346+
end
347+
348+
@tag :tmp_dir
349+
test "supports parent directory in affected_by", %{tmp_dir: tmp_dir} do
350+
Workspace.Test.with_workspace(
351+
tmp_dir,
352+
[],
353+
[
354+
{:package_a, "package_a", [workspace: [affected_by: ["../shared"]]]}
355+
],
356+
fn ->
357+
workspace = Workspace.new!(tmp_dir)
358+
workspace = Workspace.Status.update(workspace, force: true)
359+
360+
assert workspace.projects[:package_a].status == :undefined
361+
362+
# Create shared files
363+
shared_dir = Path.join(tmp_dir, "shared")
364+
File.mkdir_p!(shared_dir)
365+
366+
File.write!(Path.join(shared_dir, "config.txt"), "# config")
367+
368+
workspace = Workspace.Status.update(workspace, force: true)
369+
assert workspace.projects[:package_a].status == :affected
370+
end,
371+
git: true
372+
)
373+
end
268374
end

0 commit comments

Comments
 (0)