feat: clean up stale targets removed from config on next build
All checks were successful
Continuous Integration / Build Package (push) Successful in 28s
Continuous Integration / Lint, Check & Test (push) Successful in 47s

When a target is present in the state file but no longer in the config,
its output file is deleted (or archived if archive_folder is set) and
its state entry is removed. This runs at the start of every build.
This commit is contained in:
Konstantin Fickel 2026-02-21 18:51:39 +01:00
parent 7503672942
commit d8e0ed561d
Signed by: kfickel
GPG key ID: A793722F9933C1A5
3 changed files with 167 additions and 0 deletions

View file

@ -709,6 +709,135 @@ class TestContentTarget:
assert result.failed == {}
class TestStaleTargetCleanup:
"""Tests for cleanup of targets removed from config."""
async def test_stale_target_file_deleted(
self, project_dir: Path, write_config: WriteConfig
) -> None:
config1 = write_config(
{
"targets": {
"a.txt": {"prompt": "first"},
"b.txt": {"prompt": "second"},
}
}
)
with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
r1 = await run_build(config1, project_dir, _PROJECT)
assert set(r1.built) == {"a.txt", "b.txt"}
# Remove b.txt from config
config2 = write_config({"targets": {"a.txt": {"prompt": "first"}}})
r2 = await run_build(config2, project_dir, _PROJECT)
assert not (project_dir / "b.txt").exists()
assert "a.txt" in r2.skipped
async def test_stale_target_archived(
self, project_dir: Path, write_config: WriteConfig
) -> None:
config1 = write_config(
{
"archive_folder": "archive",
"targets": {
"a.txt": {"prompt": "first"},
"b.txt": {"prompt": "second"},
},
}
)
with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
r1 = await run_build(config1, project_dir, _PROJECT)
assert set(r1.built) == {"a.txt", "b.txt"}
config2 = write_config(
{
"archive_folder": "archive",
"targets": {"a.txt": {"prompt": "first"}},
}
)
_ = await run_build(config2, project_dir, _PROJECT)
assert not (project_dir / "b.txt").exists()
assert (project_dir / "archive" / "b.01.txt").exists()
async def test_stale_state_entry_removed(
self, project_dir: Path, write_config: WriteConfig
) -> None:
config1 = write_config(
{
"targets": {
"a.txt": {"prompt": "first"},
"b.txt": {"prompt": "second"},
}
}
)
with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
_ = await run_build(config1, project_dir, _PROJECT)
state = load_state(project_dir, _PROJECT)
assert "b.txt" in state.targets
config2 = write_config({"targets": {"a.txt": {"prompt": "first"}}})
_ = await run_build(config2, project_dir, _PROJECT)
state = load_state(project_dir, _PROJECT)
assert "b.txt" not in state.targets
assert "a.txt" in state.targets
async def test_stale_target_file_already_gone(
self, project_dir: Path, write_config: WriteConfig
) -> None:
config1 = write_config(
{
"targets": {
"a.txt": {"prompt": "first"},
"b.txt": {"prompt": "second"},
}
}
)
with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
_ = await run_build(config1, project_dir, _PROJECT)
(project_dir / "b.txt").unlink()
config2 = write_config({"targets": {"a.txt": {"prompt": "first"}}})
_ = await run_build(config2, project_dir, _PROJECT)
# State entry should still be cleaned even if file is gone
state = load_state(project_dir, _PROJECT)
assert "b.txt" not in state.targets
async def test_stale_cleanup_emits_progress(
self, project_dir: Path, write_config: WriteConfig
) -> None:
from hokusai.builder import BuildEvent
config1 = write_config(
{
"targets": {
"a.txt": {"prompt": "first"},
"b.txt": {"prompt": "second"},
}
}
)
events: list[tuple[BuildEvent, str, str]] = []
def recorder(event: BuildEvent, name: str, detail: str) -> None:
events.append((event, name, detail))
with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
_ = await run_build(config1, project_dir, _PROJECT, on_progress=recorder)
events.clear()
config2 = write_config({"targets": {"a.txt": {"prompt": "first"}}})
_ = await run_build(config2, project_dir, _PROJECT, on_progress=recorder)
removed_events = [
(e, n) for e, n, _ in events if e is BuildEvent.TARGET_REMOVED
]
assert ("b.txt",) in [(n,) for _, n in removed_events]
class TestLoopExpansion:
"""End-to-end tests for loop-expanded targets in builds."""