Make workspace/project file sync propagate deletions instead of resurrecting them
The old sync compared only the two live sides (project rows vs workspace files), so a file present in the project but missing on disk was indistinguishable from "never materialized here yet" - it always got re-exported, which is why deleting a file inside a container made it come back. The mirror direction had the same bug: a file deleted from the project's file editor was silently re-imported from the container's stale copy on the next tick. Fixes it with a persisted per-file sync baseline (new project_file_sync_state table: db_epoch/fs_epoch as they stood right after the previous sync), the same role a rsync/Unison state file plays in any real bidirectional sync. Deleting on either side now propagates to the other, unless the deleted side's counterpart was edited after the last sync, in which case the edit wins and the file is restored. A read-only project always exports (never imports, including on tie) and always removes a workspace's stale local copy, so it stays a faithful mirror. Sync of an unchanged file is now a true no-op (zero writes) instead of rewriting it every ~60s tick forever. sync_dir_bidirectional's return dict gains deleted_in_project/ deleted_in_workspace alongside exported/imported; both API call sites already pass the whole dict through untouched. The instance sync toast now summarizes all four counts instead of just imports. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjdKTWgWpW2SMNW8SFqxz5
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import os
|
||||
import pytest
|
||||
import requests
|
||||
from tests.conftest import BASE_URL, run_async
|
||||
@@ -64,6 +65,14 @@ def env(tmp_path, monkeypatch):
|
||||
for row in list(get_table("project_files").find()):
|
||||
if str(row.get("project_uid", "")).startswith("ctest"):
|
||||
get_table("project_files").delete(uid=row["uid"])
|
||||
if "project_file_sync_state" in db.tables:
|
||||
for row in list(get_table("project_file_sync_state").find()):
|
||||
if str(row.get("project_uid", "")).startswith("ctest"):
|
||||
get_table("project_file_sync_state").delete(
|
||||
project_uid=row["project_uid"], path=row["path"]
|
||||
)
|
||||
if "projects" in db.tables:
|
||||
get_table("projects").delete(uid=pid)
|
||||
def _ready_instance(env, **kwargs):
|
||||
return run_async(
|
||||
api.create_instance(env["project"], name=kwargs.pop("name", "inst"), **kwargs)
|
||||
@@ -405,6 +414,162 @@ def test_bidirectional_sync_newer_wins(env, tmp_path):
|
||||
assert imported["content"] == "from fs\n"
|
||||
|
||||
|
||||
def test_bidirectional_sync_propagates_a_workspace_deletion(env, tmp_path):
|
||||
workspace = tmp_path / "sync-del-ws"
|
||||
workspace.mkdir()
|
||||
pid = env["project"]["uid"]
|
||||
user = env["user"]
|
||||
project_files.write_text_file(pid, user, "gone.txt", "bye\n")
|
||||
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
assert (workspace / "gone.txt").exists()
|
||||
|
||||
(workspace / "gone.txt").unlink()
|
||||
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
|
||||
assert counts["deleted_in_project"] == 1
|
||||
assert counts["exported"] == 0
|
||||
assert project_files.get_node(pid, "gone.txt") is None
|
||||
assert not (workspace / "gone.txt").exists()
|
||||
|
||||
|
||||
def test_bidirectional_sync_propagates_a_project_deletion(env, tmp_path):
|
||||
workspace = tmp_path / "sync-pdel-ws"
|
||||
workspace.mkdir()
|
||||
pid = env["project"]["uid"]
|
||||
user = env["user"]
|
||||
project_files.write_text_file(pid, user, "removeme.txt", "bye\n")
|
||||
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
assert (workspace / "removeme.txt").exists()
|
||||
|
||||
project_files.delete_node(pid, "removeme.txt", deleted_by=user["uid"])
|
||||
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
|
||||
assert counts["deleted_in_workspace"] == 1
|
||||
assert counts["imported"] == 0
|
||||
assert not (workspace / "removeme.txt").exists()
|
||||
|
||||
|
||||
def test_bidirectional_sync_a_later_edit_restores_a_workspace_deletion(env, tmp_path):
|
||||
workspace = tmp_path / "sync-edit-restore-ws"
|
||||
workspace.mkdir()
|
||||
pid = env["project"]["uid"]
|
||||
user = env["user"]
|
||||
node = project_files.write_text_file(pid, user, "edited.txt", "v1\n")
|
||||
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
|
||||
(workspace / "edited.txt").unlink()
|
||||
future = datetime.now(timezone.utc) + timedelta(seconds=10)
|
||||
get_table("project_files").update(
|
||||
{"uid": node["uid"], "content": "v2\n", "updated_at": future.isoformat()},
|
||||
["uid"],
|
||||
)
|
||||
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
|
||||
assert counts["deleted_in_project"] == 0
|
||||
assert counts["exported"] == 1
|
||||
assert (workspace / "edited.txt").read_text() == "v2\n"
|
||||
|
||||
|
||||
def test_bidirectional_sync_a_later_local_edit_reimports_over_a_project_deletion(
|
||||
env, tmp_path
|
||||
):
|
||||
workspace = tmp_path / "sync-edit-reimport-ws"
|
||||
workspace.mkdir()
|
||||
pid = env["project"]["uid"]
|
||||
user = env["user"]
|
||||
project_files.write_text_file(pid, user, "revived.txt", "v1\n")
|
||||
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
|
||||
project_files.delete_node(pid, "revived.txt", deleted_by=user["uid"])
|
||||
target = workspace / "revived.txt"
|
||||
target.write_text("v2\n")
|
||||
future = (datetime.now(timezone.utc) + timedelta(seconds=10)).timestamp()
|
||||
os.utime(target, (future, future))
|
||||
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
|
||||
assert counts["deleted_in_workspace"] == 0
|
||||
assert counts["imported"] == 1
|
||||
revived = project_files.read_file(pid, "revived.txt")
|
||||
assert revived["content"] == "v2\n"
|
||||
|
||||
|
||||
def test_bidirectional_sync_readonly_always_restores_a_workspace_deletion(
|
||||
env, tmp_path
|
||||
):
|
||||
workspace = tmp_path / "sync-ro-restore-ws"
|
||||
workspace.mkdir()
|
||||
pid = env["project"]["uid"]
|
||||
user = env["user"]
|
||||
project_files.write_text_file(pid, user, "frozen.txt", "kept\n")
|
||||
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
get_table("projects").upsert(
|
||||
{"uid": pid, "slug": "ctest", "read_only": 1}, ["uid"]
|
||||
)
|
||||
|
||||
(workspace / "frozen.txt").unlink()
|
||||
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
|
||||
assert counts["deleted_in_project"] == 0
|
||||
assert counts["exported"] == 1
|
||||
assert (workspace / "frozen.txt").read_text() == "kept\n"
|
||||
assert project_files.get_node(pid, "frozen.txt") is not None
|
||||
|
||||
|
||||
def test_bidirectional_sync_readonly_removes_a_stale_local_copy(env, tmp_path):
|
||||
workspace = tmp_path / "sync-ro-remove-ws"
|
||||
workspace.mkdir()
|
||||
pid = env["project"]["uid"]
|
||||
user = env["user"]
|
||||
project_files.write_text_file(pid, user, "stale.txt", "stale\n")
|
||||
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
project_files.delete_node(pid, "stale.txt", deleted_by=user["uid"])
|
||||
get_table("projects").upsert(
|
||||
{"uid": pid, "slug": "ctest", "read_only": 1}, ["uid"]
|
||||
)
|
||||
|
||||
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
|
||||
assert counts["deleted_in_workspace"] == 1
|
||||
assert counts["imported"] == 0
|
||||
assert not (workspace / "stale.txt").exists()
|
||||
|
||||
|
||||
def test_bidirectional_sync_is_a_noop_once_both_sides_settle(env, tmp_path):
|
||||
workspace = tmp_path / "sync-noop-ws"
|
||||
workspace.mkdir()
|
||||
pid = env["project"]["uid"]
|
||||
user = env["user"]
|
||||
project_files.write_text_file(pid, user, "settled.txt", "steady\n")
|
||||
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
before = (workspace / "settled.txt").stat().st_mtime
|
||||
|
||||
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
|
||||
assert counts == {
|
||||
"exported": 0,
|
||||
"imported": 0,
|
||||
"deleted_in_project": 0,
|
||||
"deleted_in_workspace": 0,
|
||||
}
|
||||
assert (workspace / "settled.txt").stat().st_mtime == before
|
||||
|
||||
|
||||
def test_bidirectional_sync_manifest_is_pruned_after_both_sides_agree(env, tmp_path):
|
||||
workspace = tmp_path / "sync-prune-ws"
|
||||
workspace.mkdir()
|
||||
pid = env["project"]["uid"]
|
||||
user = env["user"]
|
||||
project_files.write_text_file(pid, user, "prune.txt", "x\n")
|
||||
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
(workspace / "prune.txt").unlink()
|
||||
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
|
||||
remaining = list(
|
||||
get_table("project_file_sync_state").find(project_uid=pid, path="prune.txt")
|
||||
)
|
||||
assert remaining == []
|
||||
|
||||
|
||||
|
||||
def _proxy_scope(kind: str, headers: dict, scheme: str, query: str = "") -> dict:
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user