feat: add archive_folder support for preserving previous generations

When archive_folder is set in the project config, artifacts are moved to
numbered archive copies (e.g. x.01.jpg, x.02.jpg) instead of being
overwritten or deleted.

- Build command archives existing artifacts before rebuilding dirty targets
- Clean command moves files to archive instead of deleting them
- Subfolder structure is preserved in the archive directory
- State file is always deleted, never archived
This commit is contained in:
Konstantin Fickel 2026-02-21 11:36:45 +01:00
parent 9ace38c806
commit 24cade558a
Signed by: kfickel
GPG key ID: A793722F9933C1A5
7 changed files with 272 additions and 8 deletions

View file

@ -174,6 +174,69 @@ class TestCleanCommand:
assert "Cleaned 1 artifact(s)" in result.output
assert not (cli_project / "output.txt").exists()
def test_clean_archives_when_archive_folder_set(self, tmp_path: Path) -> None:
config = {
"archive_folder": "archive",
"targets": {
"output.txt": {"prompt": "Generate text"},
"image.png": {"prompt": "Generate image"},
},
}
_ = (tmp_path / "project.hokusai.yaml").write_text(
yaml.dump(config, default_flow_style=False)
)
_ = (tmp_path / "output.txt").write_text("generated text")
_ = (tmp_path / "image.png").write_bytes(b"\x89PNG")
with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = tmp_path
result = runner.invoke(app, ["clean"])
assert result.exit_code == 0
assert "Archived 2 artifact(s)" in result.output
assert "mv" in result.output
assert not (tmp_path / "output.txt").exists()
assert not (tmp_path / "image.png").exists()
assert (tmp_path / "archive" / "output.01.txt").read_text() == "generated text"
assert (tmp_path / "archive" / "image.01.png").read_bytes() == b"\x89PNG"
def test_clean_archive_preserves_subfolders(self, tmp_path: Path) -> None:
config = {
"archive_folder": "archive",
"targets": {"img/photo.png": {"prompt": "photo"}},
}
_ = (tmp_path / "project.hokusai.yaml").write_text(
yaml.dump(config, default_flow_style=False)
)
(tmp_path / "img").mkdir()
_ = (tmp_path / "img" / "photo.png").write_bytes(b"img")
with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = tmp_path
result = runner.invoke(app, ["clean"])
assert result.exit_code == 0
assert (tmp_path / "archive" / "img" / "photo.01.png").exists()
def test_clean_archive_still_deletes_state(self, tmp_path: Path) -> None:
config = {
"archive_folder": "archive",
"targets": {"output.txt": {"prompt": "text"}},
}
_ = (tmp_path / "project.hokusai.yaml").write_text(
yaml.dump(config, default_flow_style=False)
)
_ = (tmp_path / "output.txt").write_text("data")
state_file = ".project.hokusai-state.yaml"
_ = (tmp_path / state_file).write_text("targets: {}")
with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = tmp_path
result = runner.invoke(app, ["clean"])
assert result.exit_code == 0
assert not (tmp_path / state_file).exists()
class TestGraphCommand:
"""Test the graph CLI command."""