From 38e6ec9d9fb98fec42a304d1a551690ca22d31b1 Mon Sep 17 00:00:00 2001 From: Adrien Mannocci Date: Fri, 16 Oct 2026 15:16:22 +0200 Subject: [PATCH] fix: select groups depending on changed shared files in --auto-scope A change below the shared directory was dropped because it is outside every resource group. Map it to the groups whose manifest dependencies reference it, matching directory sources by prefix. --- README.md | 2 + src/terranova/commands/helpers.py | 42 +++++++++++++- tests/it/test_helpers.py | 91 +++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4cb6be6..658988b 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,8 @@ terranova apply --strategy parallel --auto-approve --fail-at-end files (respecting `.gitignore`). - Each changed file is mapped to its nearest ancestor directory containing a `manifest.yml`; a change outside any resource group is ignored, and several changes in one group select it only once. +- A change below the `shared` directory selects every resource group whose manifest `dependencies` reference + it: a `source` that is a directory matches any file inside it. - It can't be combined with an explicit `path` (or, for `apply`, a `.tnplan` file), and it must be run from inside a git repository. - With `docs`, the docs directory isn't wiped first, so documentation of groups outside the scope is kept. diff --git a/src/terranova/commands/helpers.py b/src/terranova/commands/helpers.py index e2c47da..2182dd0 100644 --- a/src/terranova/commands/helpers.py +++ b/src/terranova/commands/helpers.py @@ -269,12 +269,46 @@ def _match_resource_dirs( return sorted(matched.values(), key=lambda entry: entry[1]) +def _match_shared_dependents( + shared_dir: Path, + all_dirs: list[tuple[Path, str]], + changed_files: list[Path], +) -> list[tuple[Path, str]]: + """ + Resource-group dirs whose manifest `dependencies` cover a changed shared file. + + A dependency `source` is a file or a directory below `shared_dir`; a changed + file matches it when it is that file or lives inside that directory. Manifests + are only read when at least one changed file is below `shared_dir`. + """ + shared_root = shared_dir.resolve() + # Resolve the parent only: a changed file may itself be a symlink to shared. + changed_shared = [ + resolved + for resolved in (f.parent.resolve() / f.name for f in changed_files) + if resolved.is_relative_to(shared_root) + ] + if not changed_shared: + return [] + + matched: list[tuple[Path, str]] = [] + for full_path, rel_path in all_dirs: + manifest = read_manifest(full_path) + for dependency in manifest.dependencies or []: + source = (shared_root / dependency.source).resolve() + if any(changed.is_relative_to(source) for changed in changed_shared): + matched.append((full_path, rel_path)) + break + return matched + + def auto_scope_resource_dirs( conf_dir: Path, resources_dir: Path ) -> list[tuple[Path, str]]: """ Scope resource dirs to those affected by the current git diff (working tree and staged changes vs HEAD, including untracked files). + A change below the `shared` dir also selects every group depending on it. This function handle errors by logging and exiting. """ # `conf_dir` is guaranteed to exist (`--conf-dir` requires it), unlike @@ -292,7 +326,13 @@ def auto_scope_resource_dirs( changed_files = [root / rel for rel in git.changed_files()] all_dirs = find_all_resource_dirs(resources_dir) - return _match_resource_dirs(resources_dir, all_dirs, changed_files) + matched = dict(_match_resource_dirs(resources_dir, all_dirs, changed_files)) + shared_dir = AppContext(conf_dir=conf_dir).shared_dir + for full_path, rel_path in _match_shared_dependents( + shared_dir, all_dirs, changed_files + ): + matched[full_path] = rel_path + return sorted(matched.items(), key=lambda entry: entry[1]) def resolve_resource_dirs( diff --git a/tests/it/test_helpers.py b/tests/it/test_helpers.py index 1c34523..188e0b9 100644 --- a/tests/it/test_helpers.py +++ b/tests/it/test_helpers.py @@ -6,6 +6,7 @@ import click import pytest +import yaml from click.exceptions import Exit from terranova.binds import Git, Terraform @@ -56,6 +57,20 @@ def _write_manifest_dir(base: Path, *parts: str) -> Path: return resource_dir +def _write_manifest_dir_depending_on(base: Path, name: str, *sources: str) -> Path: + resource_dir = base / name + resource_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "version": "1.0", + "metadata": {"name": name, "description": "test"}, + "dependencies": [ + {"source": source, "target": Path(source).name} for source in sources + ], + } + (resource_dir / "manifest.yml").write_text(yaml.safe_dump(manifest)) + return resource_dir + + def _write_manifest_dir_with_engine( base: Path, name: str, engine_name: str, version: str ) -> Path: @@ -523,6 +538,82 @@ def test_no_changes_returns_empty_list( assert auto_scope_resource_dirs(tmp_path, resources_dir) == [] +class TestAutoScopeSharedDependencies: + def test_changed_shared_file_selects_every_dependent_group( + self, tmp_path: Path, resources_dir: Path + ) -> None: + shared = tmp_path / "shared" / "providers" + shared.mkdir(parents=True) + (shared / "github.tf").write_text("# v1\n") + group_a = _write_manifest_dir_depending_on( + tmp_path / "resources", "group_a", "providers/github.tf" + ) + group_b = _write_manifest_dir_depending_on( + tmp_path / "resources", "group_b", "providers/github.tf" + ) + _write_manifest_dir(tmp_path, "resources", "group_c") + _init_git_repo(tmp_path) + + (shared / "github.tf").write_text("# v2\n") + + result = auto_scope_resource_dirs(tmp_path, resources_dir) + + assert result == [(group_a, "group_a"), (group_b, "group_b")] + + def test_changed_file_inside_shared_directory_source_matches_by_prefix( + self, tmp_path: Path, resources_dir: Path + ) -> None: + shared = tmp_path / "shared" / "scripts" / "nested" + shared.mkdir(parents=True) + (shared / "run.sh").write_text("echo 1\n") + group_a = _write_manifest_dir_depending_on( + tmp_path / "resources", "group_a", "scripts" + ) + _write_manifest_dir_depending_on( + tmp_path / "resources", "group_b", "providers/other.tf" + ) + _init_git_repo(tmp_path) + + (shared / "run.sh").write_text("echo 2\n") + + result = auto_scope_resource_dirs(tmp_path, resources_dir) + + assert result == [(group_a, "group_a")] + + def test_unreferenced_shared_file_selects_nothing( + self, tmp_path: Path, resources_dir: Path + ) -> None: + shared = tmp_path / "shared" + shared.mkdir() + (shared / "unused.tf").write_text("# v1\n") + _write_manifest_dir_depending_on( + tmp_path / "resources", "group_a", "providers/github.tf" + ) + _init_git_repo(tmp_path) + + (shared / "unused.tf").write_text("# v2\n") + + assert auto_scope_resource_dirs(tmp_path, resources_dir) == [] + + def test_group_changed_directly_and_through_shared_is_listed_once( + self, tmp_path: Path, resources_dir: Path + ) -> None: + shared = tmp_path / "shared" + shared.mkdir() + (shared / "github.tf").write_text("# v1\n") + group_a = _write_manifest_dir_depending_on( + tmp_path / "resources", "group_a", "github.tf" + ) + _init_git_repo(tmp_path) + + (shared / "github.tf").write_text("# v2\n") + (group_a / "main.tf").write_text('resource "aws_x" "y" {}\n') + + result = auto_scope_resource_dirs(tmp_path, resources_dir) + + assert result == [(group_a, "group_a")] + + class TestResolveResourceDirs: def test_path_and_auto_scope_together_raises_usage_error( self, tmp_path: Path, resources_dir: Path