Skip to content

Commit 1070472

Browse files
authored
Merge pull request #568 from Roche/nal-228
add support for .fengine-reset-ignore
2 parents 157f847 + 2cee9a3 commit 1070472

File tree

5 files changed

+262
-12
lines changed

5 files changed

+262
-12
lines changed

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ typecheck:
1111
poetry run dmypy run -- src tests
1212

1313
pre-commit: fmt lint typecheck
14+
15+
test:
16+
poetry run pytest tests

docs/source/tutorials/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
66
write-template-from-scratch
77
backwards-compatible-template-changes
8+
reset-incarnation
89
```
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Reset incarnation
2+
There is an option to reset an incarnation by removing all customizations that were done to it and bring it back to
3+
a pristine state as if it was just created freshly from the template.
4+
5+
By default, the target version and data is taken from the last change that was successfully applied
6+
to the incarnation, but they can be overridden. For the data, partial overrides are also allowed.
7+
8+
## Ignore some files from reset
9+
Sometimes, you want to keep some files in the incarnation that were created or modified after the initial
10+
creation of the incarnation. For example, you might have a `pipeline.yaml` which is created by different process.
11+
To achieve this, you can create a file called `.fengine-reset-ignore` in the root of your incarnation repository (that
12+
is about to be reset) and add the path to the file you want to preserve.
13+
14+
Additionally, if you need to ignore identical files across all incarnations, you can add .fengine-reset-ignore to your
15+
template. This file will propagate to all incarnations through foxops' usual synchronization mechanisms. That's actually
16+
the expected usecase otherwise the `.fengine-reset-ignore` file would be also deleted/overwritten during reset.
17+
18+
This file should contain a list of file paths (one per line) that should be ignored during the reset process.
19+
20+
### Examples
21+
22+
#### Ignoring top-level files and directories
23+
24+
To ignore files or directories at the root level:
25+
26+
```
27+
pipeline.yaml
28+
config/
29+
.env
30+
```
31+
32+
This will preserve `pipeline.yaml`, the entire `config/` directory, and `.env` during reset.
33+
34+
#### Ignoring specific nested files
35+
36+
You can also ignore specific files within directories while still allowing other files in the same directory to be reset:
37+
38+
```
39+
example/file1
40+
config/secrets.yaml
41+
src/generated/api.py
42+
```
43+
44+
With this configuration:
45+
- `example/file1` is preserved, but `example/file2` would be deleted
46+
- `config/secrets.yaml` is preserved, but other files in `config/` would be deleted
47+
- `src/generated/api.py` is preserved, but other files in `src/generated/` would be deleted
48+
49+
#### Combining top-level and nested exclusions
50+
51+
You can mix both styles:
52+
53+
```
54+
.env
55+
config/
56+
src/custom/special_file.py
57+
docs/generated/api-reference.md
58+
```
59+
60+
This will:
61+
- Preserve the entire `config/` directory
62+
- Preserve `.env` at the root
63+
- Preserve only `special_file.py` in `src/custom/` (other files in that directory will be deleted)
64+
- Preserve only `api-reference.md` in `docs/generated/` (other files will be deleted)
65+

src/foxops/services/change.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -772,15 +772,36 @@ def _construct_merge_request_conflict_description(
772772
return "\n\n".join(description_paragraphs)
773773

774774

775+
def _load_fengine_reset_ignore(directory: Path) -> frozenset[Path]:
776+
"""Load the content of .fengine-reset-ignore file from the given directory.
777+
The file contains a list of file/folder names (one per line) that should be
778+
skipped during file deletion in delete_all_files_in_local_git_repository.
779+
"""
780+
ignore_file = directory / ".fengine-reset-ignore"
781+
if not ignore_file.exists():
782+
return frozenset()
783+
784+
content = ignore_file.read_text()
785+
return frozenset(Path(line.strip()) for line in content.splitlines() if line.strip())
786+
787+
788+
def _is_ignored(path: Path, directory: Path, ignore_list: frozenset[Path]) -> bool:
789+
relative = path.relative_to(directory)
790+
return any(relative == ignored or relative.is_relative_to(ignored) for ignored in ignore_list)
791+
792+
775793
def delete_all_files_in_local_git_repository(directory: Path) -> None:
776-
for file in directory.glob("*"):
777-
if file.name == ".git":
778-
continue
794+
ignore_list = _load_fengine_reset_ignore(directory)
779795

780-
if file.is_dir():
781-
shutil.rmtree(file)
782-
else:
783-
file.unlink()
796+
for root, dirs, files in directory.walk(top_down=True):
797+
if ".git" in dirs:
798+
dirs.remove(".git")
799+
800+
# Delete files that aren't ignored
801+
for name in files:
802+
path = root / name
803+
if not _is_ignored(path, directory, ignore_list):
804+
path.unlink()
784805

785806

786807
def generate_foxops_branch_name(prefix: str, target_directory: str, template_repository_version: str) -> str:

tests/services/test_change.py

Lines changed: 165 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
ChangeService,
2626
IncarnationAlreadyExists,
2727
_construct_merge_request_conflict_description,
28+
_is_ignored,
29+
_load_fengine_reset_ignore,
2830
delete_all_files_in_local_git_repository,
2931
)
3032

@@ -678,6 +680,7 @@ async def test_reset_incarnation_fails_when_incarnation_does_not_exist(change_se
678680
def test_delete_all_files_in_local_git_repository_removes_hidden_directories_and_files(tmp_path):
679681
# GIVEN
680682
(tmp_path / ".dummy_folder").mkdir()
683+
(tmp_path / ".dummy_folder" / "file.txt").write_text("content")
681684
(tmp_path / "dummy_folder2").mkdir()
682685
(tmp_path / "dummy_folder2" / ".myfile").write_text("Hello, world!")
683686
(tmp_path / ".config").write_text("Hello, world!")
@@ -686,24 +689,23 @@ def test_delete_all_files_in_local_git_repository_removes_hidden_directories_and
686689
delete_all_files_in_local_git_repository(tmp_path)
687690

688691
# THEN
689-
assert not (tmp_path / ".dummy_folder").exists()
692+
assert not (tmp_path / ".dummy_folder" / "file.txt").exists()
690693
assert not (tmp_path / "dummy_folder2" / ".myfile").exists()
691694
assert not (tmp_path / ".config").exists()
692695

693696

694-
def test_delete_all_files_in_local_git_repository_does_not_delete_git_directory_in_root_folder(tmp_path):
697+
def test_delete_all_files_in_local_git_repository_does_not_delete_git_directory(tmp_path):
695698
# GIVEN
696699
(tmp_path / ".git").mkdir()
697-
(tmp_path / "subfolder").mkdir()
698-
(tmp_path / "subfolder" / ".git").mkdir()
700+
(tmp_path / ".git" / "config").write_text("git config")
699701
(tmp_path / "README.md").write_text("Hello, world!")
700702

701703
# WHEN
702704
delete_all_files_in_local_git_repository(tmp_path)
703705

704706
# THEN
705707
assert (tmp_path / ".git").exists()
706-
assert not (tmp_path / "subfolder" / ".git").exists()
708+
assert (tmp_path / ".git" / "config").exists()
707709
assert not (tmp_path / "README.md").exists()
708710

709711

@@ -714,3 +716,161 @@ async def test_diff_should_not_include_gitrepository(
714716
diff = await change_service.diff_incarnation(initialized_incarnation.id)
715717

716718
assert diff == ""
719+
720+
721+
def test_load_fengine_reset_ignore_handles_empty_lines(tmp_path):
722+
# GIVEN
723+
(tmp_path / ".fengine-reset-ignore").write_text("keep_me.txt\n\n \n\nkeep_me_too.md\n")
724+
725+
# WHEN
726+
result = _load_fengine_reset_ignore(tmp_path)
727+
728+
# THEN
729+
assert result == frozenset({Path("keep_me.txt"), Path("keep_me_too.md")})
730+
731+
732+
def test_load_fengine_reset_ignore_handles_whitespace(tmp_path):
733+
# GIVEN
734+
(tmp_path / ".fengine-reset-ignore").write_text(" keep_me.txt \n keep_folder ")
735+
736+
# WHEN
737+
result = _load_fengine_reset_ignore(tmp_path)
738+
739+
# THEN
740+
assert result == frozenset({Path("keep_me.txt"), Path("keep_folder")})
741+
742+
743+
def test_is_ignored_returns_true_for_exact_file_match():
744+
# GIVEN
745+
directory = Path("/repo")
746+
ignore_list = frozenset({Path("keep_me.txt")})
747+
748+
# WHEN / THEN
749+
assert _is_ignored(Path("/repo/keep_me.txt"), directory, ignore_list) is True
750+
751+
752+
def test_is_ignored_returns_false_for_non_matching_file():
753+
# GIVEN
754+
directory = Path("/repo")
755+
ignore_list = frozenset({Path("keep_me.txt")})
756+
757+
# WHEN / THEN
758+
assert _is_ignored(Path("/repo/delete_me.txt"), directory, ignore_list) is False
759+
760+
761+
def test_is_ignored_returns_true_for_directory_match():
762+
# GIVEN
763+
directory = Path("/repo")
764+
ignore_list = frozenset({Path("keep_folder")})
765+
766+
# WHEN / THEN
767+
assert _is_ignored(Path("/repo/keep_folder"), directory, ignore_list) is True
768+
769+
770+
def test_is_ignored_returns_true_for_nested_file_in_ignored_directory():
771+
# GIVEN
772+
directory = Path("/repo")
773+
ignore_list = frozenset({Path("keep_folder")})
774+
775+
# WHEN / THEN
776+
assert _is_ignored(Path("/repo/keep_folder/nested_file.txt"), directory, ignore_list) is True
777+
778+
779+
def test_is_ignored_returns_true_for_nested_path_exact_match():
780+
# GIVEN
781+
directory = Path("/repo")
782+
ignore_list = frozenset({Path("example/file1")})
783+
784+
# WHEN / THEN
785+
assert _is_ignored(Path("/repo/example/file1"), directory, ignore_list) is True
786+
787+
788+
def test_is_ignored_returns_false_for_sibling_of_nested_ignored_path():
789+
# GIVEN
790+
directory = Path("/repo")
791+
ignore_list = frozenset({Path("example/file1")})
792+
793+
# WHEN / THEN
794+
assert _is_ignored(Path("/repo/example/file2"), directory, ignore_list) is False
795+
796+
797+
def test_is_ignored_returns_false_for_parent_of_nested_ignored_path():
798+
# GIVEN
799+
directory = Path("/repo")
800+
ignore_list = frozenset({Path("example/file1")})
801+
802+
# WHEN / THEN
803+
assert _is_ignored(Path("/repo/example"), directory, ignore_list) is False
804+
805+
806+
def test_is_ignored_returns_true_for_deeply_nested_path():
807+
# GIVEN
808+
directory = Path("/repo")
809+
ignore_list = frozenset({Path("example/nested/deep_file")})
810+
811+
# WHEN / THEN
812+
assert _is_ignored(Path("/repo/example/nested/deep_file"), directory, ignore_list) is True
813+
814+
815+
def test_is_ignored_handles_multiple_ignore_entries():
816+
# GIVEN
817+
directory = Path("/repo")
818+
ignore_list = frozenset({Path("file1.txt"), Path("folder1"), Path("nested/file2")})
819+
820+
# WHEN / THEN
821+
assert _is_ignored(Path("/repo/file1.txt"), directory, ignore_list) is True
822+
assert _is_ignored(Path("/repo/folder1"), directory, ignore_list) is True
823+
assert _is_ignored(Path("/repo/folder1/child.txt"), directory, ignore_list) is True
824+
assert _is_ignored(Path("/repo/nested/file2"), directory, ignore_list) is True
825+
assert _is_ignored(Path("/repo/other.txt"), directory, ignore_list) is False
826+
827+
828+
def test_is_ignored_returns_false_for_empty_ignore_list():
829+
# GIVEN
830+
directory = Path("/repo")
831+
ignore_list = frozenset()
832+
833+
# WHEN / THEN
834+
assert _is_ignored(Path("/repo/any_file.txt"), directory, ignore_list) is False
835+
836+
837+
def test_delete_all_files_in_local_git_repository_respects_fengine_reset_ignore(tmp_path):
838+
# GIVEN - comprehensive end-to-end test
839+
(tmp_path / ".fengine-reset-ignore").write_text("keep_file.txt\nkeep_folder\nexample/nested/deep_file")
840+
841+
# Files to keep
842+
(tmp_path / "keep_file.txt").write_text("I should remain")
843+
(tmp_path / "keep_folder").mkdir()
844+
(tmp_path / "keep_folder" / "nested_file.txt").write_text("Nested content to keep")
845+
(tmp_path / "example").mkdir()
846+
(tmp_path / "example" / "nested").mkdir()
847+
(tmp_path / "example" / "nested" / "deep_file").write_text("I should remain")
848+
849+
# Files to delete
850+
(tmp_path / "delete_me.txt").write_text("I should be deleted")
851+
(tmp_path / "delete_folder").mkdir()
852+
(tmp_path / "delete_folder" / "some_file.txt").write_text("To be deleted")
853+
(tmp_path / "example" / "file_to_delete.txt").write_text("I should be deleted")
854+
(tmp_path / "example" / "nested" / "other_file.txt").write_text("I should be deleted")
855+
856+
# .git directory should be preserved
857+
(tmp_path / ".git").mkdir()
858+
(tmp_path / ".git" / "config").write_text("git config")
859+
860+
# WHEN
861+
delete_all_files_in_local_git_repository(tmp_path)
862+
863+
# THEN - kept files
864+
assert (tmp_path / "keep_file.txt").exists()
865+
assert (tmp_path / "keep_folder").exists()
866+
assert (tmp_path / "keep_folder" / "nested_file.txt").exists()
867+
assert (tmp_path / "example" / "nested" / "deep_file").exists()
868+
assert (tmp_path / ".git").exists()
869+
assert (tmp_path / ".git" / "config").exists()
870+
871+
# THEN - deleted files
872+
assert not (tmp_path / "delete_me.txt").exists()
873+
assert not (tmp_path / "delete_folder" / "some_file.txt").exists()
874+
assert not (tmp_path / "example" / "file_to_delete.txt").exists()
875+
assert not (tmp_path / "example" / "nested" / "other_file.txt").exists()
876+
assert not (tmp_path / ".fengine-reset-ignore").exists()

0 commit comments

Comments
 (0)