Add workspace export and baseline diff
Complete the 2.6.0 workspace milestone by adding explicit host-out export and immutable-baseline diff across the CLI, Python SDK, and MCP server. Capture a baseline archive at workspace creation, export live /workspace paths through the guest agent, and compute structured whole-workspace diffs on the host without affecting command logs or shell state. The docs, roadmap, bundled guest agent, and workspace example now reflect the new create -> sync -> diff -> export workflow. Validation: uv lock, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and a real guest-backed Firecracker smoke covering workspace create, sync push, diff, export, and delete.
This commit is contained in:
parent
3f8293ad24
commit
84a7e18d4d
26 changed files with 1492 additions and 43 deletions
|
|
@ -50,7 +50,9 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
|
|||
assert "vm_run" in tool_names
|
||||
assert "vm_create" in tool_names
|
||||
assert "workspace_create" in tool_names
|
||||
assert "workspace_diff" in tool_names
|
||||
assert "workspace_sync_push" in tool_names
|
||||
assert "workspace_export" in tool_names
|
||||
assert "shell_open" in tool_names
|
||||
assert "shell_read" in tool_names
|
||||
assert "shell_write" in tool_names
|
||||
|
|
@ -136,6 +138,9 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
(updated_dir / "more.txt").write_text("more\n", encoding="utf-8")
|
||||
synced = pyro.push_workspace_sync(workspace_id, updated_dir, dest="subdir")
|
||||
executed = pyro.exec_workspace(workspace_id, command="cat note.txt")
|
||||
diff_payload = pyro.diff_workspace(workspace_id)
|
||||
export_path = tmp_path / "exported-note.txt"
|
||||
exported = pyro.export_workspace(workspace_id, "note.txt", output_path=export_path)
|
||||
opened = pyro.open_shell(workspace_id)
|
||||
shell_id = str(opened["shell_id"])
|
||||
written = pyro.write_shell(workspace_id, shell_id, input="pwd")
|
||||
|
|
@ -154,6 +159,9 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
assert created["workspace_seed"]["mode"] == "directory"
|
||||
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
|
||||
assert written["input_length"] == 3
|
||||
assert diff_payload["changed"] is True
|
||||
assert exported["output_path"] == str(export_path)
|
||||
assert export_path.read_text(encoding="utf-8") == "ok\n"
|
||||
assert "/workspace" in read["output"]
|
||||
assert signaled["signal"] == "INT"
|
||||
assert closed["closed"] is True
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "pyro workspace create debian:12 --seed-path ./repo" in workspace_help
|
||||
assert "pyro workspace sync push WORKSPACE_ID ./repo --dest src" in workspace_help
|
||||
assert "pyro workspace exec WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace diff WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt" in workspace_help
|
||||
assert "pyro workspace shell open WORKSPACE_ID" in workspace_help
|
||||
|
||||
workspace_create_help = _subparser_choice(
|
||||
|
|
@ -93,6 +95,18 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "--dest" in workspace_sync_push_help
|
||||
assert "Import host content into `/workspace`" in workspace_sync_push_help
|
||||
|
||||
workspace_export_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "export"
|
||||
).format_help()
|
||||
assert "--output" in workspace_export_help
|
||||
assert "Export one file or directory from `/workspace`" in workspace_export_help
|
||||
|
||||
workspace_diff_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "diff"
|
||||
).format_help()
|
||||
assert "immutable workspace baseline" in workspace_diff_help
|
||||
assert "workspace export" in workspace_diff_help
|
||||
|
||||
workspace_shell_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"shell",
|
||||
|
|
@ -522,6 +536,100 @@ def test_cli_workspace_exec_prints_human_output(
|
|||
)
|
||||
|
||||
|
||||
def test_cli_workspace_export_prints_human_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def export_workspace(
|
||||
self,
|
||||
workspace_id: str,
|
||||
path: str,
|
||||
*,
|
||||
output_path: str,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert path == "note.txt"
|
||||
assert output_path == "./note.txt"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"workspace_path": "/workspace/note.txt",
|
||||
"output_path": "/tmp/note.txt",
|
||||
"artifact_type": "file",
|
||||
"entry_count": 1,
|
||||
"bytes_written": 6,
|
||||
"execution_mode": "guest_vsock",
|
||||
}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="export",
|
||||
workspace_id="workspace-123",
|
||||
path="note.txt",
|
||||
output="./note.txt",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert "[workspace-export] workspace_id=workspace-123" in output
|
||||
assert "artifact_type=file" in output
|
||||
|
||||
|
||||
def test_cli_workspace_diff_prints_human_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def diff_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"changed": True,
|
||||
"summary": {
|
||||
"total": 1,
|
||||
"added": 0,
|
||||
"modified": 1,
|
||||
"deleted": 0,
|
||||
"type_changed": 0,
|
||||
"text_patched": 1,
|
||||
"non_text": 0,
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"path": "note.txt",
|
||||
"status": "modified",
|
||||
"artifact_type": "file",
|
||||
"text_patch": "--- a/note.txt\n+++ b/note.txt\n",
|
||||
}
|
||||
],
|
||||
"patch": "--- a/note.txt\n+++ b/note.txt\n",
|
||||
}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="diff",
|
||||
workspace_id="workspace-123",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert (
|
||||
"[workspace-diff] workspace_id=workspace-123 total=1 added=0 modified=1"
|
||||
in output
|
||||
)
|
||||
assert "--- a/note.txt" in output
|
||||
|
||||
|
||||
def test_cli_workspace_sync_push_prints_json(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ from pyro_mcp.contract import (
|
|||
PUBLIC_CLI_ENV_SUBCOMMANDS,
|
||||
PUBLIC_CLI_RUN_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS,
|
||||
|
|
@ -92,6 +94,16 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
|||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS:
|
||||
assert flag in workspace_sync_push_help_text
|
||||
workspace_export_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "export"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS:
|
||||
assert flag in workspace_export_help_text
|
||||
workspace_diff_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "diff"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_DIFF_FLAGS:
|
||||
assert flag in workspace_diff_help_text
|
||||
workspace_shell_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"shell",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
|
|||
assert "vm_run" in tool_names
|
||||
assert "vm_status" in tool_names
|
||||
assert "workspace_create" in tool_names
|
||||
assert "workspace_diff" in tool_names
|
||||
assert "workspace_export" in tool_names
|
||||
assert "workspace_logs" in tool_names
|
||||
assert "workspace_sync_push" in tool_names
|
||||
assert "shell_open" in tool_names
|
||||
|
|
@ -201,6 +203,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
]:
|
||||
server = create_server(manager=manager)
|
||||
created = _extract_structured(
|
||||
|
|
@ -236,6 +240,20 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
},
|
||||
)
|
||||
)
|
||||
diffed = _extract_structured(
|
||||
await server.call_tool("workspace_diff", {"workspace_id": workspace_id})
|
||||
)
|
||||
export_path = tmp_path / "exported-more.txt"
|
||||
exported = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_export",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"path": "subdir/more.txt",
|
||||
"output_path": str(export_path),
|
||||
},
|
||||
)
|
||||
)
|
||||
opened = _extract_structured(
|
||||
await server.call_tool("shell_open", {"workspace_id": workspace_id})
|
||||
)
|
||||
|
|
@ -296,12 +314,27 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
deleted = _extract_structured(
|
||||
await server.call_tool("workspace_delete", {"workspace_id": workspace_id})
|
||||
)
|
||||
return created, synced, executed, opened, written, read, signaled, closed, logs, deleted
|
||||
return (
|
||||
created,
|
||||
synced,
|
||||
executed,
|
||||
diffed,
|
||||
exported,
|
||||
opened,
|
||||
written,
|
||||
read,
|
||||
signaled,
|
||||
closed,
|
||||
logs,
|
||||
deleted,
|
||||
)
|
||||
|
||||
(
|
||||
created,
|
||||
synced,
|
||||
executed,
|
||||
diffed,
|
||||
exported,
|
||||
opened,
|
||||
written,
|
||||
read,
|
||||
|
|
@ -314,6 +347,9 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
assert created["workspace_seed"]["mode"] == "directory"
|
||||
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
|
||||
assert executed["stdout"] == "more\n"
|
||||
assert diffed["changed"] is True
|
||||
assert exported["artifact_type"] == "file"
|
||||
assert Path(str(exported["output_path"])).read_text(encoding="utf-8") == "more\n"
|
||||
assert opened["state"] == "running"
|
||||
assert written["input_length"] == 3
|
||||
assert "/workspace" in read["output"]
|
||||
|
|
|
|||
|
|
@ -105,6 +105,54 @@ def test_vsock_exec_client_upload_archive_round_trip(
|
|||
assert stub.closed is True
|
||||
|
||||
|
||||
def test_vsock_exec_client_export_archive_round_trip(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False)
|
||||
archive_bytes = io.BytesIO()
|
||||
with tarfile.open(fileobj=archive_bytes, mode="w") as archive:
|
||||
payload = b"hello\n"
|
||||
info = tarfile.TarInfo(name="note.txt")
|
||||
info.size = len(payload)
|
||||
archive.addfile(info, io.BytesIO(payload))
|
||||
archive_payload = archive_bytes.getvalue()
|
||||
header = json.dumps(
|
||||
{
|
||||
"workspace_path": "/workspace/note.txt",
|
||||
"artifact_type": "file",
|
||||
"archive_size": len(archive_payload),
|
||||
"entry_count": 1,
|
||||
"bytes_written": 6,
|
||||
}
|
||||
).encode("utf-8") + b"\n"
|
||||
stub = StubSocket(header + archive_payload)
|
||||
|
||||
def socket_factory(family: int, sock_type: int) -> StubSocket:
|
||||
assert family == socket.AF_VSOCK
|
||||
assert sock_type == socket.SOCK_STREAM
|
||||
return stub
|
||||
|
||||
client = VsockExecClient(socket_factory=socket_factory)
|
||||
archive_path = tmp_path / "export.tar"
|
||||
response = client.export_archive(
|
||||
1234,
|
||||
5005,
|
||||
workspace_path="/workspace/note.txt",
|
||||
archive_path=archive_path,
|
||||
timeout_seconds=60,
|
||||
)
|
||||
|
||||
request = json.loads(stub.sent.decode("utf-8").strip())
|
||||
assert request["action"] == "export_archive"
|
||||
assert request["path"] == "/workspace/note.txt"
|
||||
assert archive_path.read_bytes() == archive_payload
|
||||
assert response.workspace_path == "/workspace/note.txt"
|
||||
assert response.artifact_type == "file"
|
||||
assert response.entry_count == 1
|
||||
assert response.bytes_written == 6
|
||||
assert stub.closed is True
|
||||
|
||||
|
||||
def test_vsock_exec_client_shell_round_trip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False)
|
||||
responses = [
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
|
|
@ -454,6 +455,239 @@ def test_workspace_sync_push_rejects_destination_outside_workspace(tmp_path: Pat
|
|||
manager.push_workspace_sync(workspace_id, source_path=source_dir, dest="../escape")
|
||||
|
||||
|
||||
def test_workspace_diff_and_export_round_trip(tmp_path: Path) -> None:
|
||||
seed_dir = tmp_path / "seed"
|
||||
seed_dir.mkdir()
|
||||
(seed_dir / "note.txt").write_text("hello\n", encoding="utf-8")
|
||||
update_dir = tmp_path / "update"
|
||||
update_dir.mkdir()
|
||||
(update_dir / "note.txt").write_text("hello from sync\n", encoding="utf-8")
|
||||
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
seed_path=seed_dir,
|
||||
)["workspace_id"]
|
||||
)
|
||||
manager.push_workspace_sync(workspace_id, source_path=update_dir)
|
||||
|
||||
diff_payload = manager.diff_workspace(workspace_id)
|
||||
assert diff_payload["workspace_id"] == workspace_id
|
||||
assert diff_payload["changed"] is True
|
||||
assert diff_payload["summary"]["modified"] == 1
|
||||
assert diff_payload["summary"]["text_patched"] == 1
|
||||
assert "-hello\n" in diff_payload["patch"]
|
||||
assert "+hello from sync\n" in diff_payload["patch"]
|
||||
|
||||
output_path = tmp_path / "exported-note.txt"
|
||||
export_payload = manager.export_workspace(
|
||||
workspace_id,
|
||||
path="note.txt",
|
||||
output_path=output_path,
|
||||
)
|
||||
assert export_payload["workspace_id"] == workspace_id
|
||||
assert export_payload["artifact_type"] == "file"
|
||||
assert output_path.read_text(encoding="utf-8") == "hello from sync\n"
|
||||
|
||||
status = manager.status_workspace(workspace_id)
|
||||
logs = manager.logs_workspace(workspace_id)
|
||||
assert status["command_count"] == 0
|
||||
assert logs["count"] == 0
|
||||
|
||||
|
||||
def test_workspace_export_directory_uses_exact_output_path(tmp_path: Path) -> None:
|
||||
seed_dir = tmp_path / "seed"
|
||||
nested_dir = seed_dir / "src"
|
||||
nested_dir.mkdir(parents=True)
|
||||
(nested_dir / "note.txt").write_text("hello\n", encoding="utf-8")
|
||||
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
seed_path=seed_dir,
|
||||
)["workspace_id"]
|
||||
)
|
||||
|
||||
output_dir = tmp_path / "exported-src"
|
||||
payload = manager.export_workspace(workspace_id, path="src", output_path=output_dir)
|
||||
assert payload["artifact_type"] == "directory"
|
||||
assert (output_dir / "note.txt").read_text(encoding="utf-8") == "hello\n"
|
||||
assert not (output_dir / "src").exists()
|
||||
|
||||
|
||||
def test_workspace_diff_requires_create_time_baseline(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["workspace_id"]
|
||||
)
|
||||
baseline_path = tmp_path / "vms" / "workspaces" / workspace_id / "baseline" / "workspace.tar"
|
||||
baseline_path.unlink()
|
||||
|
||||
with pytest.raises(RuntimeError, match="requires a baseline snapshot"):
|
||||
manager.diff_workspace(workspace_id)
|
||||
|
||||
|
||||
def test_workspace_export_helpers_preserve_directory_symlinks(tmp_path: Path) -> None:
|
||||
workspace_dir = tmp_path / "workspace"
|
||||
workspace_dir.mkdir()
|
||||
(workspace_dir / "note.txt").write_text("hello\n", encoding="utf-8")
|
||||
os.symlink("note.txt", workspace_dir / "note-link")
|
||||
(workspace_dir / "empty-dir").mkdir()
|
||||
|
||||
archive_path = tmp_path / "workspace-export.tar"
|
||||
exported = vm_manager_module._prepare_workspace_export_archive( # noqa: SLF001
|
||||
workspace_dir=workspace_dir,
|
||||
workspace_path=".",
|
||||
archive_path=archive_path,
|
||||
)
|
||||
|
||||
assert exported.artifact_type == "directory"
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
extracted = vm_manager_module._extract_workspace_export_archive( # noqa: SLF001
|
||||
archive_path,
|
||||
output_path=output_dir,
|
||||
artifact_type="directory",
|
||||
)
|
||||
|
||||
assert extracted["artifact_type"] == "directory"
|
||||
assert (output_dir / "note.txt").read_text(encoding="utf-8") == "hello\n"
|
||||
assert (output_dir / "note-link").is_symlink()
|
||||
assert os.readlink(output_dir / "note-link") == "note.txt"
|
||||
assert (output_dir / "empty-dir").is_dir()
|
||||
|
||||
|
||||
def test_workspace_export_helpers_validate_missing_path_and_existing_output(tmp_path: Path) -> None:
|
||||
workspace_dir = tmp_path / "workspace"
|
||||
workspace_dir.mkdir()
|
||||
(workspace_dir / "note.txt").write_text("hello\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(RuntimeError, match="workspace path does not exist"):
|
||||
vm_manager_module._prepare_workspace_export_archive( # noqa: SLF001
|
||||
workspace_dir=workspace_dir,
|
||||
workspace_path="missing.txt",
|
||||
archive_path=tmp_path / "missing.tar",
|
||||
)
|
||||
|
||||
archive_path = tmp_path / "note-export.tar"
|
||||
exported = vm_manager_module._prepare_workspace_export_archive( # noqa: SLF001
|
||||
workspace_dir=workspace_dir,
|
||||
workspace_path="note.txt",
|
||||
archive_path=archive_path,
|
||||
)
|
||||
output_path = tmp_path / "note.txt"
|
||||
output_path.write_text("already here\n", encoding="utf-8")
|
||||
with pytest.raises(RuntimeError, match="output_path already exists"):
|
||||
vm_manager_module._extract_workspace_export_archive( # noqa: SLF001
|
||||
archive_path,
|
||||
output_path=output_path,
|
||||
artifact_type=exported.artifact_type,
|
||||
)
|
||||
|
||||
|
||||
def test_diff_workspace_trees_reports_empty_binary_symlink_and_type_changes(tmp_path: Path) -> None:
|
||||
baseline_dir = tmp_path / "baseline"
|
||||
current_dir = tmp_path / "current"
|
||||
baseline_dir.mkdir()
|
||||
current_dir.mkdir()
|
||||
|
||||
(baseline_dir / "modified.txt").write_text("before\n", encoding="utf-8")
|
||||
(current_dir / "modified.txt").write_text("after\n", encoding="utf-8")
|
||||
|
||||
(baseline_dir / "deleted.txt").write_text("gone\n", encoding="utf-8")
|
||||
(current_dir / "added.txt").write_text("new\n", encoding="utf-8")
|
||||
|
||||
(baseline_dir / "binary.bin").write_bytes(b"\x00before")
|
||||
(current_dir / "binary.bin").write_bytes(b"\x00after")
|
||||
|
||||
os.symlink("link-target-old.txt", baseline_dir / "link")
|
||||
os.symlink("link-target-new.txt", current_dir / "link")
|
||||
|
||||
(baseline_dir / "swap").mkdir()
|
||||
(current_dir / "swap").write_text("type changed\n", encoding="utf-8")
|
||||
|
||||
(baseline_dir / "removed-empty").mkdir()
|
||||
(current_dir / "added-empty").mkdir()
|
||||
|
||||
diff_payload = vm_manager_module._diff_workspace_trees( # noqa: SLF001
|
||||
baseline_dir,
|
||||
current_dir,
|
||||
)
|
||||
|
||||
assert diff_payload["changed"] is True
|
||||
assert diff_payload["summary"] == {
|
||||
"total": 8,
|
||||
"added": 2,
|
||||
"modified": 3,
|
||||
"deleted": 2,
|
||||
"type_changed": 1,
|
||||
"text_patched": 3,
|
||||
"non_text": 5,
|
||||
}
|
||||
assert "--- a/modified.txt" in diff_payload["patch"]
|
||||
assert "+++ b/modified.txt" in diff_payload["patch"]
|
||||
assert "--- /dev/null" in diff_payload["patch"]
|
||||
assert "+++ b/added.txt" in diff_payload["patch"]
|
||||
assert "--- a/deleted.txt" in diff_payload["patch"]
|
||||
assert "+++ /dev/null" in diff_payload["patch"]
|
||||
entries = {entry["path"]: entry for entry in diff_payload["entries"]}
|
||||
assert entries["binary.bin"]["text_patch"] is None
|
||||
assert entries["link"]["artifact_type"] == "symlink"
|
||||
assert entries["swap"]["artifact_type"] == "file"
|
||||
assert entries["removed-empty"]["artifact_type"] == "directory"
|
||||
assert entries["added-empty"]["artifact_type"] == "directory"
|
||||
|
||||
|
||||
def test_diff_workspace_trees_unchanged_returns_empty_summary(tmp_path: Path) -> None:
|
||||
baseline_dir = tmp_path / "baseline"
|
||||
current_dir = tmp_path / "current"
|
||||
baseline_dir.mkdir()
|
||||
current_dir.mkdir()
|
||||
(baseline_dir / "note.txt").write_text("same\n", encoding="utf-8")
|
||||
(current_dir / "note.txt").write_text("same\n", encoding="utf-8")
|
||||
|
||||
diff_payload = vm_manager_module._diff_workspace_trees( # noqa: SLF001
|
||||
baseline_dir,
|
||||
current_dir,
|
||||
)
|
||||
|
||||
assert diff_payload == {
|
||||
"changed": False,
|
||||
"summary": {
|
||||
"total": 0,
|
||||
"added": 0,
|
||||
"modified": 0,
|
||||
"deleted": 0,
|
||||
"type_changed": 0,
|
||||
"text_patched": 0,
|
||||
"non_text": 0,
|
||||
},
|
||||
"entries": [],
|
||||
"patch": "",
|
||||
}
|
||||
|
||||
|
||||
def test_workspace_shell_lifecycle_and_rehydration(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue