Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ typecheck:
poetry run dmypy run -- src tests

pre-commit: fmt lint typecheck

test:
poetry run pytest tests
1 change: 1 addition & 0 deletions docs/source/tutorials/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@

write-template-from-scratch
backwards-compatible-template-changes
reset-incarnation
```
61 changes: 61 additions & 0 deletions docs/source/tutorials/reset-incarnation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# 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 template. It would propagate it
to the root of your incarnation repository.

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)

47 changes: 41 additions & 6 deletions src/foxops/services/change.py
Original file line number Diff line number Diff line change
Expand Up @@ -772,15 +772,50 @@ 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":
ignore_list = _load_fengine_reset_ignore(directory)

# Walk bottom-up so we process children before parents
for root, dirs, files in directory.walk(top_down=False):
# Skip .git directory and its contents entirely
if ".git" in root.parts:
continue

if file.is_dir():
shutil.rmtree(file)
else:
file.unlink()
# Delete files that aren't ignored
for name in files:
path = root / name
if not _is_ignored(path, directory, ignore_list):
path.unlink()

# Delete directories that aren't ignored and are now empty
for name in dirs:
if name == ".git" and root == directory:
continue

path = root / name
if not _is_ignored(path, directory, ignore_list):
try:
path.rmdir()
except OSError:
pass


def generate_foxops_branch_name(prefix: str, target_directory: str, template_repository_version: str) -> str:
Expand Down
160 changes: 160 additions & 0 deletions tests/services/test_change.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
ChangeService,
IncarnationAlreadyExists,
_construct_merge_request_conflict_description,
_is_ignored,
_load_fengine_reset_ignore,
delete_all_files_in_local_git_repository,
)

Expand Down Expand Up @@ -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").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()