diff --git a/Makefile b/Makefile index f9de977d..41ffdfcb 100644 --- a/Makefile +++ b/Makefile @@ -11,3 +11,6 @@ typecheck: poetry run dmypy run -- src tests pre-commit: fmt lint typecheck + +test: + poetry run pytest tests diff --git a/docs/source/tutorials/index.md b/docs/source/tutorials/index.md index 91cefe4d..219501a1 100644 --- a/docs/source/tutorials/index.md +++ b/docs/source/tutorials/index.md @@ -5,4 +5,5 @@ write-template-from-scratch backwards-compatible-template-changes +reset-incarnation ``` diff --git a/docs/source/tutorials/reset-incarnation.md b/docs/source/tutorials/reset-incarnation.md new file mode 100644 index 00000000..777a8f07 --- /dev/null +++ b/docs/source/tutorials/reset-incarnation.md @@ -0,0 +1,65 @@ +# Reset incarnation +There is an option to reset an incarnation by removing all customizations that were done to it and bring it back to +a pristine state as if it was just created freshly from the template. + +By default, the target version and data is taken from the last change that was successfully applied +to the incarnation, but they can be overridden. For the data, partial overrides are also allowed. + +## Ignore some files from reset +Sometimes, you want to keep some files in the incarnation that were created or modified after the initial +creation of the incarnation. For example, you might have a `pipeline.yaml` which is created by different process. +To achieve this, you can create a file called `.fengine-reset-ignore` in the root of your incarnation repository (that +is about to be reset) and add the path to the file you want to preserve. + +Additionally, if you need to ignore identical files across all incarnations, you can add .fengine-reset-ignore to your +template. This file will propagate to all incarnations through foxops' usual synchronization mechanisms. That's actually +the expected usecase otherwise the `.fengine-reset-ignore` file would be also deleted/overwritten during reset. + +This file should contain a list of file paths (one per line) that should be ignored during the reset process. + +### Examples + +#### Ignoring top-level files and directories + +To ignore files or directories at the root level: + +``` +pipeline.yaml +config/ +.env +``` + +This will preserve `pipeline.yaml`, the entire `config/` directory, and `.env` during reset. + +#### Ignoring specific nested files + +You can also ignore specific files within directories while still allowing other files in the same directory to be reset: + +``` +example/file1 +config/secrets.yaml +src/generated/api.py +``` + +With this configuration: +- `example/file1` is preserved, but `example/file2` would be deleted +- `config/secrets.yaml` is preserved, but other files in `config/` would be deleted +- `src/generated/api.py` is preserved, but other files in `src/generated/` would be deleted + +#### Combining top-level and nested exclusions + +You can mix both styles: + +``` +.env +config/ +src/custom/special_file.py +docs/generated/api-reference.md +``` + +This will: +- Preserve the entire `config/` directory +- Preserve `.env` at the root +- Preserve only `special_file.py` in `src/custom/` (other files in that directory will be deleted) +- Preserve only `api-reference.md` in `docs/generated/` (other files will be deleted) + diff --git a/src/foxops/services/change.py b/src/foxops/services/change.py index b31b3ab2..16b77bb1 100644 --- a/src/foxops/services/change.py +++ b/src/foxops/services/change.py @@ -772,15 +772,36 @@ def _construct_merge_request_conflict_description( return "\n\n".join(description_paragraphs) +def _load_fengine_reset_ignore(directory: Path) -> frozenset[Path]: + """Load the content of .fengine-reset-ignore file from the given directory. + The file contains a list of file/folder names (one per line) that should be + skipped during file deletion in delete_all_files_in_local_git_repository. + """ + ignore_file = directory / ".fengine-reset-ignore" + if not ignore_file.exists(): + return frozenset() + + content = ignore_file.read_text() + return frozenset(Path(line.strip()) for line in content.splitlines() if line.strip()) + + +def _is_ignored(path: Path, directory: Path, ignore_list: frozenset[Path]) -> bool: + relative = path.relative_to(directory) + return any(relative == ignored or relative.is_relative_to(ignored) for ignored in ignore_list) + + def delete_all_files_in_local_git_repository(directory: Path) -> None: - for file in directory.glob("*"): - if file.name == ".git": - continue + ignore_list = _load_fengine_reset_ignore(directory) - if file.is_dir(): - shutil.rmtree(file) - else: - file.unlink() + for root, dirs, files in directory.walk(top_down=True): + if ".git" in dirs: + dirs.remove(".git") + + # Delete files that aren't ignored + for name in files: + path = root / name + if not _is_ignored(path, directory, ignore_list): + path.unlink() def generate_foxops_branch_name(prefix: str, target_directory: str, template_repository_version: str) -> str: diff --git a/tests/services/test_change.py b/tests/services/test_change.py index 87962964..a58b53f5 100644 --- a/tests/services/test_change.py +++ b/tests/services/test_change.py @@ -25,6 +25,8 @@ ChangeService, IncarnationAlreadyExists, _construct_merge_request_conflict_description, + _is_ignored, + _load_fengine_reset_ignore, delete_all_files_in_local_git_repository, ) @@ -678,6 +680,7 @@ async def test_reset_incarnation_fails_when_incarnation_does_not_exist(change_se def test_delete_all_files_in_local_git_repository_removes_hidden_directories_and_files(tmp_path): # GIVEN (tmp_path / ".dummy_folder").mkdir() + (tmp_path / ".dummy_folder" / "file.txt").write_text("content") (tmp_path / "dummy_folder2").mkdir() (tmp_path / "dummy_folder2" / ".myfile").write_text("Hello, world!") (tmp_path / ".config").write_text("Hello, world!") @@ -686,16 +689,15 @@ def test_delete_all_files_in_local_git_repository_removes_hidden_directories_and delete_all_files_in_local_git_repository(tmp_path) # THEN - assert not (tmp_path / ".dummy_folder").exists() + assert not (tmp_path / ".dummy_folder" / "file.txt").exists() assert not (tmp_path / "dummy_folder2" / ".myfile").exists() assert not (tmp_path / ".config").exists() -def test_delete_all_files_in_local_git_repository_does_not_delete_git_directory_in_root_folder(tmp_path): +def test_delete_all_files_in_local_git_repository_does_not_delete_git_directory(tmp_path): # GIVEN (tmp_path / ".git").mkdir() - (tmp_path / "subfolder").mkdir() - (tmp_path / "subfolder" / ".git").mkdir() + (tmp_path / ".git" / "config").write_text("git config") (tmp_path / "README.md").write_text("Hello, world!") # WHEN @@ -703,7 +705,7 @@ def test_delete_all_files_in_local_git_repository_does_not_delete_git_directory_ # THEN assert (tmp_path / ".git").exists() - assert not (tmp_path / "subfolder" / ".git").exists() + assert (tmp_path / ".git" / "config").exists() assert not (tmp_path / "README.md").exists() @@ -714,3 +716,161 @@ async def test_diff_should_not_include_gitrepository( diff = await change_service.diff_incarnation(initialized_incarnation.id) assert diff == "" + + +def test_load_fengine_reset_ignore_handles_empty_lines(tmp_path): + # GIVEN + (tmp_path / ".fengine-reset-ignore").write_text("keep_me.txt\n\n \n\nkeep_me_too.md\n") + + # WHEN + result = _load_fengine_reset_ignore(tmp_path) + + # THEN + assert result == frozenset({Path("keep_me.txt"), Path("keep_me_too.md")}) + + +def test_load_fengine_reset_ignore_handles_whitespace(tmp_path): + # GIVEN + (tmp_path / ".fengine-reset-ignore").write_text(" keep_me.txt \n keep_folder ") + + # WHEN + result = _load_fengine_reset_ignore(tmp_path) + + # THEN + assert result == frozenset({Path("keep_me.txt"), Path("keep_folder")}) + + +def test_is_ignored_returns_true_for_exact_file_match(): + # GIVEN + directory = Path("/repo") + ignore_list = frozenset({Path("keep_me.txt")}) + + # WHEN / THEN + assert _is_ignored(Path("/repo/keep_me.txt"), directory, ignore_list) is True + + +def test_is_ignored_returns_false_for_non_matching_file(): + # GIVEN + directory = Path("/repo") + ignore_list = frozenset({Path("keep_me.txt")}) + + # WHEN / THEN + assert _is_ignored(Path("/repo/delete_me.txt"), directory, ignore_list) is False + + +def test_is_ignored_returns_true_for_directory_match(): + # GIVEN + directory = Path("/repo") + ignore_list = frozenset({Path("keep_folder")}) + + # WHEN / THEN + assert _is_ignored(Path("/repo/keep_folder"), directory, ignore_list) is True + + +def test_is_ignored_returns_true_for_nested_file_in_ignored_directory(): + # GIVEN + directory = Path("/repo") + ignore_list = frozenset({Path("keep_folder")}) + + # WHEN / THEN + assert _is_ignored(Path("/repo/keep_folder/nested_file.txt"), directory, ignore_list) is True + + +def test_is_ignored_returns_true_for_nested_path_exact_match(): + # GIVEN + directory = Path("/repo") + ignore_list = frozenset({Path("example/file1")}) + + # WHEN / THEN + assert _is_ignored(Path("/repo/example/file1"), directory, ignore_list) is True + + +def test_is_ignored_returns_false_for_sibling_of_nested_ignored_path(): + # GIVEN + directory = Path("/repo") + ignore_list = frozenset({Path("example/file1")}) + + # WHEN / THEN + assert _is_ignored(Path("/repo/example/file2"), directory, ignore_list) is False + + +def test_is_ignored_returns_false_for_parent_of_nested_ignored_path(): + # GIVEN + directory = Path("/repo") + ignore_list = frozenset({Path("example/file1")}) + + # WHEN / THEN + assert _is_ignored(Path("/repo/example"), directory, ignore_list) is False + + +def test_is_ignored_returns_true_for_deeply_nested_path(): + # GIVEN + directory = Path("/repo") + ignore_list = frozenset({Path("example/nested/deep_file")}) + + # WHEN / THEN + assert _is_ignored(Path("/repo/example/nested/deep_file"), directory, ignore_list) is True + + +def test_is_ignored_handles_multiple_ignore_entries(): + # GIVEN + directory = Path("/repo") + ignore_list = frozenset({Path("file1.txt"), Path("folder1"), Path("nested/file2")}) + + # WHEN / THEN + assert _is_ignored(Path("/repo/file1.txt"), directory, ignore_list) is True + assert _is_ignored(Path("/repo/folder1"), directory, ignore_list) is True + assert _is_ignored(Path("/repo/folder1/child.txt"), directory, ignore_list) is True + assert _is_ignored(Path("/repo/nested/file2"), directory, ignore_list) is True + assert _is_ignored(Path("/repo/other.txt"), directory, ignore_list) is False + + +def test_is_ignored_returns_false_for_empty_ignore_list(): + # GIVEN + directory = Path("/repo") + ignore_list = frozenset() + + # WHEN / THEN + assert _is_ignored(Path("/repo/any_file.txt"), directory, ignore_list) is False + + +def test_delete_all_files_in_local_git_repository_respects_fengine_reset_ignore(tmp_path): + # GIVEN - comprehensive end-to-end test + (tmp_path / ".fengine-reset-ignore").write_text("keep_file.txt\nkeep_folder\nexample/nested/deep_file") + + # Files to keep + (tmp_path / "keep_file.txt").write_text("I should remain") + (tmp_path / "keep_folder").mkdir() + (tmp_path / "keep_folder" / "nested_file.txt").write_text("Nested content to keep") + (tmp_path / "example").mkdir() + (tmp_path / "example" / "nested").mkdir() + (tmp_path / "example" / "nested" / "deep_file").write_text("I should remain") + + # Files to delete + (tmp_path / "delete_me.txt").write_text("I should be deleted") + (tmp_path / "delete_folder").mkdir() + (tmp_path / "delete_folder" / "some_file.txt").write_text("To be deleted") + (tmp_path / "example" / "file_to_delete.txt").write_text("I should be deleted") + (tmp_path / "example" / "nested" / "other_file.txt").write_text("I should be deleted") + + # .git directory should be preserved + (tmp_path / ".git").mkdir() + (tmp_path / ".git" / "config").write_text("git config") + + # WHEN + delete_all_files_in_local_git_repository(tmp_path) + + # THEN - kept files + assert (tmp_path / "keep_file.txt").exists() + assert (tmp_path / "keep_folder").exists() + assert (tmp_path / "keep_folder" / "nested_file.txt").exists() + assert (tmp_path / "example" / "nested" / "deep_file").exists() + assert (tmp_path / ".git").exists() + assert (tmp_path / ".git" / "config").exists() + + # THEN - deleted files + assert not (tmp_path / "delete_me.txt").exists() + assert not (tmp_path / "delete_folder" / "some_file.txt").exists() + assert not (tmp_path / "example" / "file_to_delete.txt").exists() + assert not (tmp_path / "example" / "nested" / "other_file.txt").exists() + assert not (tmp_path / ".fengine-reset-ignore").exists()