1166 lines
46 KiB
Python
1166 lines
46 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
import yaml
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import delete, select
|
|
|
|
from memrelay.git_service import GitManager
|
|
from memrelay.lease_service import RuntimeLeaseManager
|
|
from memrelay.models import (
|
|
CuratedDocument,
|
|
CuratedDocumentRevision,
|
|
CurationDependencyState,
|
|
CurationJob,
|
|
CurationModelConfig,
|
|
CurationSettings,
|
|
GitConnection,
|
|
GitSyncJob,
|
|
MemoryReference,
|
|
MemoryRepository,
|
|
Project,
|
|
UsageProfile,
|
|
)
|
|
from memrelay.schemas import GitModeMigrationRequest
|
|
from tests.conftest import csrf_headers
|
|
from tests.fakes import FakeBasicMemory
|
|
|
|
pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="Git CLI is not installed")
|
|
|
|
|
|
class DiskBasicMemory(FakeBasicMemory):
|
|
def __init__(self, root: Path) -> None:
|
|
super().__init__()
|
|
self.root = root
|
|
|
|
async def write_note(self, **kwargs): # type: ignore[no-untyped-def]
|
|
result = await super().write_note(**kwargs)
|
|
path = self.root / f"{result['permalink']}.md"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
metadata = {
|
|
**kwargs["metadata"],
|
|
"title": kwargs["title"],
|
|
"tags": kwargs["tags"],
|
|
"type": kwargs["note_type"],
|
|
}
|
|
path.write_text(
|
|
f"---\n{yaml.safe_dump(metadata, allow_unicode=True, sort_keys=False)}---\n\n"
|
|
f"{kwargs['content']}\n",
|
|
encoding="utf-8",
|
|
)
|
|
return result
|
|
|
|
async def move_note(self, identifier: str, destination_path: str) -> dict:
|
|
source = self.root / f"{identifier}.md"
|
|
result = await super().move_note(identifier, destination_path)
|
|
destination = self.root / f"{result['permalink']}.md"
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
source.replace(destination)
|
|
return result
|
|
|
|
async def delete_note(self, identifier: str) -> None:
|
|
await super().delete_note(identifier)
|
|
(self.root / f"{identifier}.md").unlink(missing_ok=True)
|
|
|
|
|
|
def _git(path, *args: str) -> str: # type: ignore[no-untyped-def]
|
|
result = subprocess.run(
|
|
["git", "-C", str(path), *args],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def _enable_local_git(client: TestClient, remote_url: str | None = None) -> dict:
|
|
response = client.put(
|
|
"/api/v1/git/connection",
|
|
json={
|
|
"enabled": True,
|
|
"name": "Local memory history",
|
|
"mode": "single",
|
|
"remote_url": remote_url,
|
|
"author_name": "MemRelay Tests",
|
|
"author_email": "memrelay@example.test",
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
repositories = client.get("/api/v1/git/repositories")
|
|
assert repositories.status_code == 200
|
|
assert len(repositories.json()) == 1
|
|
return repositories.json()[0]
|
|
|
|
|
|
def _save_memory(client: TestClient, request_id: str, title: str, content: str) -> dict:
|
|
response = client.post(
|
|
"/api/v1/memories",
|
|
json={
|
|
"request_id": request_id,
|
|
"scope": "global",
|
|
"memory_type": "fact",
|
|
"title": title,
|
|
"content": content,
|
|
"tags": ["git-test"],
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
return response.json()
|
|
|
|
|
|
def _write_version(repository_path, memory: dict, content: str) -> str: # type: ignore[no-untyped-def]
|
|
relative = f"{memory['path']}.md"
|
|
target = repository_path / relative
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(
|
|
"---\n"
|
|
f"stable_id: {memory['id']}\n"
|
|
"scope: global\n"
|
|
"memory_type: fact\n"
|
|
f"title: {memory['title']}\n"
|
|
"tags:\n"
|
|
" - git-test\n"
|
|
"---\n\n"
|
|
f"{content}\n",
|
|
encoding="utf-8",
|
|
)
|
|
_git(repository_path, "add", relative)
|
|
_git(repository_path, "commit", "-m", f"test: {content}")
|
|
return _git(repository_path, "rev-parse", "HEAD")
|
|
|
|
|
|
def _create_remote_memory(remote: Path, source: Path, memory_id: str) -> str:
|
|
_git(source.parent, "init", "-b", "main", str(source))
|
|
_git(source, "config", "user.name", "Remote Test")
|
|
_git(source, "config", "user.email", "remote@example.test")
|
|
(source / "global").mkdir(parents=True)
|
|
(source / "memrelay-manifest.json").write_text(
|
|
'{"schema_version":1,"repository_mode":"single","scope":"all","projects":[]}\n',
|
|
encoding="utf-8",
|
|
)
|
|
(source / "global" / "remote-memory.md").write_text(
|
|
"---\n"
|
|
f"stable_id: {memory_id}\n"
|
|
"scope: global\n"
|
|
"memory_type: fact\n"
|
|
"title: Remote memory\n"
|
|
"tags:\n"
|
|
" - remote\n"
|
|
"---\n\n"
|
|
"Imported from remote.\n",
|
|
encoding="utf-8",
|
|
)
|
|
_git(source, "add", "-A")
|
|
_git(source, "commit", "-m", "feat: add remote memory")
|
|
commit = _git(source, "rev-parse", "HEAD")
|
|
_git(source.parent, "init", "--bare", str(remote))
|
|
_git(source, "remote", "add", "origin", str(remote))
|
|
_git(source, "push", "-u", "origin", "main")
|
|
return commit
|
|
|
|
|
|
def _drain_git_jobs(client: TestClient, limit: int = 20) -> None:
|
|
for _ in range(limit):
|
|
if not asyncio.run(client.app.state.git_manager.process_once()):
|
|
return
|
|
raise AssertionError("Git sync jobs did not drain")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mode", "ai_enabled", "git_enabled", "remote_enabled"),
|
|
[
|
|
("base", False, False, False),
|
|
("ai_only", True, False, False),
|
|
("local_git", False, True, False),
|
|
("git_remote", False, True, True),
|
|
("ai_git", True, True, False),
|
|
],
|
|
)
|
|
def test_ai_and_git_optional_feature_matrix(
|
|
initialized_client: TestClient,
|
|
tmp_path: Path,
|
|
mode: str,
|
|
ai_enabled: bool,
|
|
git_enabled: bool,
|
|
remote_enabled: bool,
|
|
) -> None:
|
|
client = initialized_client
|
|
fake = DiskBasicMemory(client.app.state.settings.memories_dir)
|
|
client.app.state.basic_memory = fake
|
|
client.app.state.curation.basic_memory = fake
|
|
client.app.state.git_manager.basic_memory = fake
|
|
if ai_enabled:
|
|
with client.app.state.session_factory() as db:
|
|
settings = db.get(CurationSettings, 1)
|
|
dependency = db.get(CurationDependencyState, 1)
|
|
assert settings is not None and dependency is not None
|
|
settings.enabled = True
|
|
dependency.status = "available"
|
|
db.add(
|
|
CurationModelConfig(
|
|
revision=1,
|
|
name=f"{mode}-model",
|
|
base_url="http://model.invalid/v1",
|
|
text_model="test-model",
|
|
protocol="responses",
|
|
effective_protocol="responses",
|
|
active=True,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
remote: Path | None = None
|
|
if git_enabled:
|
|
if remote_enabled:
|
|
remote = tmp_path / "matrix-remote.git"
|
|
subprocess.run(["git", "init", "--bare", str(remote)], check=True, capture_output=True)
|
|
_enable_local_git(client, str(remote) if remote else None)
|
|
|
|
saved = _save_memory(
|
|
client,
|
|
f"matrix-{mode}",
|
|
f"Matrix {mode}",
|
|
f"The {mode} optional feature combination remains usable.",
|
|
)
|
|
assert saved["revision"] == 1
|
|
with client.app.state.session_factory() as db:
|
|
curation_jobs = db.scalars(select(CurationJob)).all()
|
|
git_jobs = db.scalars(select(GitSyncJob)).all()
|
|
repositories = db.scalars(select(MemoryRepository)).all()
|
|
assert bool(curation_jobs) is ai_enabled
|
|
assert bool(git_jobs) is git_enabled
|
|
assert bool(repositories) is git_enabled
|
|
assert (client.app.state.settings.memories_dir / ".git").exists() is git_enabled
|
|
|
|
if remote is not None:
|
|
_drain_git_jobs(client)
|
|
assert _git(remote, "rev-parse", "refs/heads/main")
|
|
|
|
|
|
def test_project_deletion_is_committed_before_repository_archive(
|
|
initialized_client: TestClient,
|
|
) -> None:
|
|
client = initialized_client
|
|
fake = DiskBasicMemory(client.app.state.settings.memories_dir)
|
|
client.app.state.basic_memory = fake
|
|
client.app.state.curation.basic_memory = fake
|
|
client.app.state.git_manager.basic_memory = fake
|
|
project = client.post(
|
|
"/api/v1/projects",
|
|
json={"name": "Archived history"},
|
|
headers=csrf_headers(client),
|
|
).json()
|
|
enabled = client.put(
|
|
"/api/v1/git/connection",
|
|
json={
|
|
"enabled": True,
|
|
"name": "Workspace history",
|
|
"mode": "per_workspace",
|
|
"author_name": "MemRelay Tests",
|
|
"author_email": "memrelay@example.test",
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert enabled.status_code == 200, enabled.text
|
|
|
|
saved = client.post(
|
|
"/api/v1/memories",
|
|
json={
|
|
"request_id": "project-delete-history",
|
|
"scope": "project",
|
|
"project_id": project["id"],
|
|
"memory_type": "decision",
|
|
"title": "Recoverable project decision",
|
|
"content": "This revision must remain recoverable after project deletion.",
|
|
"tags": ["history"],
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert saved.status_code == 201, saved.text
|
|
memory = saved.json()
|
|
_drain_git_jobs(client)
|
|
|
|
with client.app.state.session_factory() as db:
|
|
repository = db.scalar(
|
|
select(MemoryRepository).where(MemoryRepository.project_id == project["id"])
|
|
)
|
|
assert repository is not None
|
|
repository_id = repository.id
|
|
original_path = Path(repository.local_path)
|
|
relative = Path(f"{memory['path']}.md").relative_to(Path("projects") / project["id"])
|
|
assert (original_path / relative).exists()
|
|
|
|
archived = client.post(
|
|
f"/api/v1/projects/{project['id']}/archive",
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert archived.status_code == 200, archived.text
|
|
with client.app.state.session_factory() as db:
|
|
repository = db.get(MemoryRepository, repository_id)
|
|
assert repository is not None
|
|
assert repository.project_id == project["id"]
|
|
assert Path(repository.local_path) == original_path
|
|
|
|
deleted = client.delete(
|
|
f"/api/v1/projects/{project['id']}",
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert deleted.status_code == 204, deleted.text
|
|
with client.app.state.session_factory() as db:
|
|
repository = db.get(MemoryRepository, repository_id)
|
|
assert repository is not None
|
|
assert repository.status == "archived"
|
|
assert repository.project_id is None
|
|
archive_path = Path(repository.local_path)
|
|
deletion_job = db.scalar(
|
|
select(GitSyncJob).where(GitSyncJob.operation_id == f"project-delete:{project['id']}")
|
|
)
|
|
assert deletion_job is not None
|
|
assert deletion_job.status == "completed"
|
|
assert archive_path.is_dir()
|
|
assert "delete: 永久删除项目及其记忆" in _git(archive_path, "log", "--format=%s")
|
|
filtered_history = client.get(
|
|
f"/api/v1/git/repositories/{repository_id}/history",
|
|
params={
|
|
"author": "MemRelay Tests",
|
|
"action": "create",
|
|
"resource_id": memory["id"],
|
|
"project_id": project["id"],
|
|
},
|
|
)
|
|
assert filtered_history.status_code == 200, filtered_history.text
|
|
assert len(filtered_history.json()) == 1
|
|
assert filtered_history.json()[0]["action"] == "create"
|
|
assert filtered_history.json()[0]["resource_id"] == memory["id"]
|
|
assert filtered_history.json()[0]["project_id"] == project["id"]
|
|
previous_commit = _git(archive_path, "rev-parse", "HEAD^")
|
|
version = client.get(
|
|
f"/api/v1/git/repositories/{repository_id}/versions/{memory['id']}",
|
|
params={"commit": previous_commit},
|
|
)
|
|
assert version.status_code == 200, version.text
|
|
assert "This revision must remain recoverable" in version.json()["content"]
|
|
previous = _git(archive_path, "show", f"HEAD^:{relative.as_posix()}")
|
|
assert "This revision must remain recoverable" in previous
|
|
missing = subprocess.run(
|
|
["git", "-C", str(archive_path), "cat-file", "-e", f"HEAD:{relative.as_posix()}"],
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
assert missing.returncode != 0
|
|
|
|
restored = client.post(
|
|
f"/api/v1/git/repositories/{repository_id}/restore",
|
|
json={"commit": previous_commit},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert restored.status_code == 200, restored.text
|
|
assert restored.json()["restored"] == 1
|
|
with client.app.state.session_factory() as db:
|
|
repository = db.get(MemoryRepository, repository_id)
|
|
recreated = db.get(Project, project["id"])
|
|
reference = db.get(MemoryReference, memory["id"])
|
|
assert repository is not None and repository.project_id == project["id"]
|
|
assert repository.status != "archived" and repository.archived_at is None
|
|
assert recreated is not None and recreated.archived is False
|
|
assert reference is not None and reference.status == "active"
|
|
assert Path(client.app.state.settings.memories_dir / f"{memory['path']}.md").exists()
|
|
|
|
|
|
def test_project_source_git_uses_requested_branch_and_offline_cache(
|
|
initialized_client: TestClient,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = tmp_path / "source-workspace"
|
|
_git(tmp_path, "init", "-b", "main", str(source))
|
|
_git(source, "config", "user.name", "Source Test")
|
|
_git(source, "config", "user.email", "source@example.test")
|
|
(source / "README.md").write_text("main branch\n", encoding="utf-8")
|
|
_git(source, "add", "README.md")
|
|
_git(source, "commit", "-m", "main source")
|
|
_git(source, "checkout", "-b", "release/docs")
|
|
(source / "README.md").write_text("release branch documentation\n", encoding="utf-8")
|
|
_git(source, "add", "README.md")
|
|
_git(source, "commit", "-m", "release source")
|
|
release_commit = _git(source, "rev-parse", "HEAD")
|
|
|
|
with initialized_client.app.state.session_factory() as db:
|
|
project = Project(
|
|
name="Source branch",
|
|
slug="source-branch",
|
|
git_remote=str(source),
|
|
git_remote_normalized="git://local/source-branch",
|
|
source_strategy="git",
|
|
source_branch="release/docs",
|
|
)
|
|
db.add(project)
|
|
db.commit()
|
|
db.refresh(project)
|
|
project_id = project.id
|
|
db.expunge(project)
|
|
|
|
sources = asyncio.run(initialized_client.app.state.curation._collect_git(project))
|
|
assert any("release branch documentation" in item.content for item in sources)
|
|
with initialized_client.app.state.session_factory() as db:
|
|
current = db.get(Project, project_id)
|
|
assert current is not None
|
|
assert current.source_last_branch == "release/docs"
|
|
assert current.source_last_commit == release_commit
|
|
assert current.source_cache_used is False
|
|
|
|
unavailable = tmp_path / "source-workspace-offline"
|
|
source.rename(unavailable)
|
|
cached = asyncio.run(initialized_client.app.state.curation._collect_git(project))
|
|
assert any("release branch documentation" in item.content for item in cached)
|
|
with initialized_client.app.state.session_factory() as db:
|
|
current = db.get(Project, project_id)
|
|
assert current is not None
|
|
assert current.source_cache_used is True
|
|
assert current.source_last_error == "GIT_SOURCE_REFRESH_FAILED_USING_CACHE"
|
|
|
|
|
|
def test_source_git_incremental_diff_and_unchanged_short_circuit(
|
|
initialized_client: TestClient,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = tmp_path / "diff-source"
|
|
_git(tmp_path, "init", "-b", "main", str(source))
|
|
_git(source, "config", "user.name", "Diff Test")
|
|
_git(source, "config", "user.email", "diff@example.test")
|
|
(source / "README.md").write_text("initial readme\n", encoding="utf-8")
|
|
(source / "keep.md").write_text("stable content\n", encoding="utf-8")
|
|
_git(source, "add", "-A")
|
|
_git(source, "commit", "-m", "initial")
|
|
first_commit = _git(source, "rev-parse", "HEAD")
|
|
|
|
with initialized_client.app.state.session_factory() as db:
|
|
project = Project(
|
|
name="Diff source",
|
|
slug="diff-source",
|
|
git_remote=str(source),
|
|
git_remote_normalized="git://local/diff-source",
|
|
source_strategy="git",
|
|
)
|
|
db.add(project)
|
|
db.commit()
|
|
db.refresh(project)
|
|
db.expunge(project)
|
|
|
|
manager = initialized_client.app.state.curation
|
|
|
|
def make_snapshot() -> dict:
|
|
return {
|
|
"text_file_max_bytes": 2 * 1024 * 1024,
|
|
"rich_file_max_bytes": 50 * 1024 * 1024,
|
|
"max_source_files": 1000,
|
|
"max_source_chars": 20_000_000,
|
|
}
|
|
|
|
# 无基线:全量收集整个仓库。
|
|
full_snapshot = make_snapshot()
|
|
full_sources = asyncio.run(manager._collect_git(project, full_snapshot))
|
|
assert {item.source_id for item in full_sources} == {"README.md", "keep.md"}
|
|
assert full_snapshot["git_coverage"]["mode"] == "full"
|
|
|
|
# 基线等于当前 HEAD:git 部分零来源短路。
|
|
unchanged_snapshot = make_snapshot()
|
|
unchanged = asyncio.run(
|
|
manager._collect_git(project, unchanged_snapshot, base_commit=first_commit)
|
|
)
|
|
assert unchanged == []
|
|
assert unchanged_snapshot["git_coverage"]["mode"] == "unchanged"
|
|
assert unchanged_snapshot["git_coverage"]["base_commit"] == first_commit
|
|
|
|
# 修改 + 新增 + 删除后,按基线 diff 只收集变化并声明删除。
|
|
(source / "README.md").write_text("updated readme\n", encoding="utf-8")
|
|
(source / "new.md").write_text("new file\n", encoding="utf-8")
|
|
_git(source, "rm", "keep.md")
|
|
_git(source, "add", "-A")
|
|
_git(source, "commit", "-m", "changes")
|
|
|
|
diff_snapshot = make_snapshot()
|
|
diff_sources = asyncio.run(
|
|
manager._collect_git(project, diff_snapshot, base_commit=first_commit)
|
|
)
|
|
by_id = {item.source_id: item for item in diff_sources}
|
|
assert set(by_id) == {"README.md", "new.md", "keep.md"}
|
|
assert "updated readme" in by_id["README.md"].content
|
|
assert by_id["keep.md"].reason == "SOURCE_REMOVED"
|
|
assert "was removed" in by_id["keep.md"].content
|
|
assert diff_snapshot["git_coverage"]["mode"] == "diff"
|
|
|
|
# 基线 commit 在本地不可用(如浅克隆丢失)时回退全量。
|
|
fallback_snapshot = make_snapshot()
|
|
fallback = asyncio.run(manager._collect_git(project, fallback_snapshot, base_commit="0" * 40))
|
|
assert {item.source_id for item in fallback} == {"README.md", "new.md"}
|
|
assert fallback_snapshot["git_coverage"]["mode"] == "full"
|
|
|
|
|
|
def test_version_and_snapshot_restore_create_new_state_without_rewriting_history(
|
|
initialized_client: TestClient,
|
|
) -> None:
|
|
client = initialized_client
|
|
fake = DiskBasicMemory(client.app.state.settings.memories_dir)
|
|
client.app.state.basic_memory = fake
|
|
client.app.state.curation.basic_memory = fake
|
|
client.app.state.git_manager.basic_memory = fake
|
|
repository = _enable_local_git(client)
|
|
repository_path = client.app.state.settings.memories_dir
|
|
|
|
memory = _save_memory(client, "git-memory-1", "History memory", "Current content")
|
|
old_commit = _write_version(repository_path, memory, "Historical content")
|
|
_write_version(repository_path, memory, "Current content")
|
|
|
|
archived = client.post(
|
|
f"/api/v1/memories/{memory['id']}/archive",
|
|
params={"expected_revision": memory["revision"]},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert archived.status_code == 200, archived.text
|
|
assert archived.json()["status"] == "archived"
|
|
|
|
restored = client.post(
|
|
f"/api/v1/git/repositories/{repository['id']}/versions/{memory['id']}/restore",
|
|
json={"commit": old_commit},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert restored.status_code == 200, restored.text
|
|
assert restored.json()["revision"] == 3
|
|
assert restored.json()["status"] == "active"
|
|
assert fake.notes[memory["path"]]["content"].strip() == "Historical content"
|
|
assert f"archive/{memory['id']}" not in fake.notes
|
|
|
|
missing = _save_memory(client, "git-memory-2", "Missing from snapshot", "Later content")
|
|
with client.app.state.session_factory() as db:
|
|
db.execute(delete(GitSyncJob))
|
|
record = db.scalar(select(MemoryRepository).where(MemoryRepository.id == repository["id"]))
|
|
assert record is not None
|
|
record.pending_commits = 0
|
|
db.commit()
|
|
|
|
snapshot = client.post(
|
|
f"/api/v1/git/repositories/{repository['id']}/restore",
|
|
json={"commit": old_commit},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert snapshot.status_code == 200, snapshot.text
|
|
assert snapshot.json()["restored"] == 1
|
|
assert snapshot.json()["archived"] == 1
|
|
|
|
with client.app.state.session_factory() as db:
|
|
restored_reference = db.get(MemoryReference, memory["id"])
|
|
missing_reference = db.get(MemoryReference, missing["id"])
|
|
jobs = db.scalars(select(GitSyncJob)).all()
|
|
assert restored_reference is not None and restored_reference.status == "active"
|
|
assert restored_reference.revision == 4
|
|
assert missing_reference is not None and missing_reference.status == "archived"
|
|
assert len(jobs) == 1
|
|
assert jobs[0].action == "restore_snapshot"
|
|
|
|
assert _git(repository_path, "rev-parse", old_commit) == old_commit
|
|
|
|
|
|
def test_curated_git_restore_and_rebuild_preserve_metadata_and_profile(
|
|
initialized_client: TestClient,
|
|
) -> None:
|
|
client = initialized_client
|
|
fake = DiskBasicMemory(client.app.state.settings.memories_dir)
|
|
client.app.state.basic_memory = fake
|
|
client.app.state.curation.basic_memory = fake
|
|
client.app.state.git_manager.basic_memory = fake
|
|
repository = _enable_local_git(client)
|
|
repository_path = client.app.state.settings.memories_dir
|
|
document_id = "33333333-3333-4333-8333-333333333333"
|
|
with client.app.state.session_factory() as db:
|
|
profile_id = db.scalar(select(UsageProfile.id).where(UsageProfile.is_shared.is_(True)))
|
|
assert profile_id is not None
|
|
base_metadata = {
|
|
"stable_id": document_id,
|
|
"scope": "global",
|
|
"project_id": None,
|
|
"workspace_type": "global",
|
|
"usage_profile_id": profile_id,
|
|
"preference_context": "global",
|
|
"document_type": "preferences",
|
|
"source_memory_ids": ["memory-evidence"],
|
|
"source_checkpoint_ids": ["checkpoint-evidence"],
|
|
"source_file_ids": ["file-evidence"],
|
|
"source_git_commit": "4" * 40,
|
|
"model_connection": "historical-connection",
|
|
"model_name": "historical-model",
|
|
"prompt_version": "historical-prompt",
|
|
"curation_job_id": None,
|
|
"source_hash": "a" * 64,
|
|
"source_cursor": 7,
|
|
"created_at": "2026-08-04T01:02:03+00:00",
|
|
"updated_at": "2026-08-04T01:02:03+00:00",
|
|
"title": "Curated history",
|
|
"tags": ["curated"],
|
|
"type": "curated",
|
|
}
|
|
historical_metadata = {**base_metadata, "revision": 1}
|
|
historical = asyncio.run(
|
|
fake.write_note(
|
|
title="Curated history",
|
|
content="Historical curated content.",
|
|
directory=f"global/curated/{profile_id}",
|
|
tags=["curated"],
|
|
note_type="curated",
|
|
metadata=historical_metadata,
|
|
overwrite=False,
|
|
)
|
|
)
|
|
relative = f"{historical['permalink']}.md"
|
|
_git(repository_path, "add", relative)
|
|
_git(repository_path, "commit", "-m", "test: historical curated document")
|
|
historical_commit = _git(repository_path, "rev-parse", "HEAD")
|
|
|
|
current_metadata = {
|
|
**base_metadata,
|
|
"revision": 2,
|
|
"source_hash": "b" * 64,
|
|
"source_cursor": 11,
|
|
"model_name": "current-model",
|
|
"updated_at": "2026-08-04T02:02:03+00:00",
|
|
}
|
|
asyncio.run(
|
|
fake.write_note(
|
|
title="Curated history",
|
|
content="Current curated content.",
|
|
directory=f"global/curated/{profile_id}",
|
|
tags=["curated"],
|
|
note_type="curated",
|
|
metadata=current_metadata,
|
|
overwrite=True,
|
|
)
|
|
)
|
|
_git(repository_path, "add", relative)
|
|
_git(repository_path, "commit", "-m", "test: current curated document")
|
|
with client.app.state.session_factory() as db:
|
|
document = CuratedDocument(
|
|
id=document_id,
|
|
scope="global",
|
|
project_id=None,
|
|
usage_profile_id=profile_id,
|
|
document_type="preferences",
|
|
title="Curated history",
|
|
path=historical["permalink"],
|
|
revision=2,
|
|
source_hash="b" * 64,
|
|
source_cursor=11,
|
|
metadata_json=json.dumps(current_metadata),
|
|
model="current-model",
|
|
prompt_version="historical-prompt",
|
|
)
|
|
db.add(document)
|
|
for revision, content, metadata in (
|
|
(1, "Historical curated content.", historical_metadata),
|
|
(2, "Current curated content.", current_metadata),
|
|
):
|
|
history_path = (
|
|
client.app.state.settings.curated_history_dir / document_id / f"{revision}.md"
|
|
)
|
|
history_path.parent.mkdir(parents=True, exist_ok=True)
|
|
history_path.write_text(content, encoding="utf-8")
|
|
db.add(
|
|
CuratedDocumentRevision(
|
|
document_id=document_id,
|
|
revision=revision,
|
|
storage_path=str(history_path.relative_to(client.app.state.settings.data_dir)),
|
|
model=str(metadata["model_name"]),
|
|
source_hash=str(metadata["source_hash"]),
|
|
metadata_json=json.dumps(metadata),
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
restored = client.post(
|
|
f"/api/v1/git/repositories/{repository['id']}/versions/{document_id}/restore",
|
|
json={"commit": historical_commit},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert restored.status_code == 200, restored.text
|
|
assert restored.json()["revision"] == 3
|
|
with client.app.state.session_factory() as db:
|
|
current = db.get(CuratedDocument, document_id)
|
|
revision = db.scalar(
|
|
select(CuratedDocumentRevision).where(
|
|
CuratedDocumentRevision.document_id == document_id,
|
|
CuratedDocumentRevision.revision == 3,
|
|
)
|
|
)
|
|
assert current is not None and revision is not None
|
|
current_metadata_after_restore = json.loads(current.metadata_json)
|
|
revision_metadata = json.loads(revision.metadata_json)
|
|
assert current.usage_profile_id == profile_id
|
|
assert current.source_hash == "a" * 64
|
|
assert current.source_cursor == 7
|
|
assert current.model == "git-restore"
|
|
assert current_metadata_after_restore["source_memory_ids"] == ["memory-evidence"]
|
|
assert current_metadata_after_restore["restored_from_commit"] == historical_commit
|
|
assert current_metadata_after_restore["revision"] == 3
|
|
assert revision_metadata == current_metadata_after_restore
|
|
|
|
db.delete(current)
|
|
db.commit()
|
|
asyncio.run(fake.delete_note(historical["permalink"]))
|
|
version = client.app.state.git_manager.version_get(
|
|
repository["id"], historical_commit, document_id
|
|
)
|
|
rebuilt = asyncio.run(client.app.state.git_manager._create_from_version(version))
|
|
assert rebuilt["kind"] == "curated"
|
|
with client.app.state.session_factory() as db:
|
|
current = db.get(CuratedDocument, document_id)
|
|
revision = db.scalar(
|
|
select(CuratedDocumentRevision).where(
|
|
CuratedDocumentRevision.document_id == document_id,
|
|
CuratedDocumentRevision.revision == 1,
|
|
)
|
|
)
|
|
assert current is not None and revision is not None
|
|
rebuilt_metadata = json.loads(current.metadata_json)
|
|
assert current.usage_profile_id == profile_id
|
|
assert current.path.startswith(f"global/curated/{profile_id}/")
|
|
assert rebuilt_metadata["source_checkpoint_ids"] == ["checkpoint-evidence"]
|
|
assert json.loads(revision.metadata_json) == rebuilt_metadata
|
|
|
|
|
|
def test_remote_inspect_import_and_unbind(
|
|
initialized_client: TestClient,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
client = initialized_client
|
|
fake = DiskBasicMemory(client.app.state.settings.memories_dir)
|
|
client.app.state.basic_memory = fake
|
|
client.app.state.curation.basic_memory = fake
|
|
client.app.state.git_manager.basic_memory = fake
|
|
memory_id = "11111111-1111-4111-8111-111111111111"
|
|
remote = tmp_path / "memory-remote.git"
|
|
_create_remote_memory(remote, tmp_path / "source", memory_id)
|
|
repository = _enable_local_git(client, str(remote))
|
|
|
|
inspected = client.post(
|
|
f"/api/v1/git/repositories/{repository['id']}/inspect",
|
|
json={"branch": "main"},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert inspected.status_code == 200, inspected.text
|
|
assert inspected.json()["valid"] is True
|
|
assert inspected.json()["relationship"] == "unrelated"
|
|
assert inspected.json()["stable_ids"] == [memory_id]
|
|
with client.app.state.session_factory() as db:
|
|
assert db.get(MemoryReference, memory_id) is None
|
|
|
|
imported = client.post(
|
|
f"/api/v1/git/repositories/{repository['id']}/remote/import",
|
|
json={"scope": "document", "resource_id": memory_id, "branch": "main"},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert imported.status_code == 200, imported.text
|
|
assert imported.json()["imported"] == 1
|
|
with client.app.state.session_factory() as db:
|
|
reference = db.get(MemoryReference, memory_id)
|
|
assert reference is not None and reference.status == "active"
|
|
assert fake.notes["global/remote-memory"]["content"].strip() == "Imported from remote."
|
|
|
|
unbound = client.delete(
|
|
f"/api/v1/git/repositories/{repository['id']}/remote",
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert unbound.status_code == 200, unbound.text
|
|
assert unbound.json()["remote_url"] is None
|
|
assert "origin" not in _git(client.app.state.settings.memories_dir, "remote").splitlines()
|
|
|
|
|
|
def test_remote_bootstrap_preserves_remote_history(
|
|
initialized_client: TestClient,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
client = initialized_client
|
|
fake = DiskBasicMemory(client.app.state.settings.memories_dir)
|
|
client.app.state.basic_memory = fake
|
|
client.app.state.curation.basic_memory = fake
|
|
client.app.state.git_manager.basic_memory = fake
|
|
memory_id = "22222222-2222-4222-8222-222222222222"
|
|
remote = tmp_path / "bootstrap-remote.git"
|
|
remote_commit = _create_remote_memory(remote, tmp_path / "bootstrap-source", memory_id)
|
|
repository = _enable_local_git(client, str(remote))
|
|
|
|
bootstrap = client.post(
|
|
"/api/v1/git/repositories/bootstrap",
|
|
json={
|
|
"repository_id": repository["id"],
|
|
"branch": "main",
|
|
"confirmed_empty": True,
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert bootstrap.status_code == 200, bootstrap.text
|
|
result = bootstrap.json()
|
|
assert result["remote_commit"] == remote_commit
|
|
assert result["restored"] == 1
|
|
assert (
|
|
_git(
|
|
client.app.state.settings.memories_dir,
|
|
"merge-base",
|
|
"--is-ancestor",
|
|
remote_commit,
|
|
"HEAD",
|
|
)
|
|
== ""
|
|
)
|
|
with client.app.state.session_factory() as db:
|
|
reference = db.get(MemoryReference, memory_id)
|
|
repository_record = db.get(MemoryRepository, repository["id"])
|
|
assert reference is not None and reference.status == "active"
|
|
assert repository_record is not None
|
|
assert repository_record.last_pushed_commit == remote_commit
|
|
|
|
|
|
def test_initial_baseline_is_queued_and_push_to_create_setting_is_enforced(
|
|
initialized_client: TestClient,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
client = initialized_client
|
|
remote = tmp_path / "empty-remote.git"
|
|
subprocess.run(["git", "init", "--bare", str(remote)], check=True, capture_output=True)
|
|
repository = _enable_local_git(client, str(remote))
|
|
with client.app.state.session_factory() as db:
|
|
jobs = db.scalars(
|
|
select(GitSyncJob).where(GitSyncJob.repository_id == repository["id"])
|
|
).all()
|
|
assert len(jobs) == 1
|
|
assert jobs[0].operation_id.startswith("baseline-sync:")
|
|
assert asyncio.run(client.app.state.git_manager.process_once()) is True
|
|
assert _git(remote, "rev-parse", "refs/heads/main")
|
|
synchronized = client.get(f"/api/v1/git/repositories/{repository['id']}/status").json()
|
|
assert synchronized["status"] == "synced"
|
|
|
|
client.delete("/api/v1/git/connection", headers=csrf_headers(client))
|
|
shutil.rmtree(client.app.state.settings.memories_dir / ".git")
|
|
missing_remote = tmp_path / "missing.git"
|
|
response = client.put(
|
|
"/api/v1/git/connection",
|
|
json={
|
|
"enabled": True,
|
|
"name": "No remote creation",
|
|
"mode": "single",
|
|
"remote_url": str(missing_remote),
|
|
"allow_push_to_create": False,
|
|
"author_name": "MemRelay Tests",
|
|
"author_email": "memrelay@example.test",
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
current = client.get("/api/v1/git/repositories").json()[0]
|
|
assert asyncio.run(client.app.state.git_manager.process_once()) is True
|
|
state = client.get(f"/api/v1/git/repositories/{current['id']}/status").json()
|
|
assert state["status"] == "remote_repository_missing"
|
|
assert not missing_remote.exists()
|
|
|
|
|
|
def test_repository_mode_can_migrate_round_trip(initialized_client: TestClient) -> None:
|
|
client = initialized_client
|
|
fake = DiskBasicMemory(client.app.state.settings.memories_dir)
|
|
client.app.state.basic_memory = fake
|
|
client.app.state.curation.basic_memory = fake
|
|
client.app.state.git_manager.basic_memory = fake
|
|
project_response = client.post(
|
|
"/api/v1/projects",
|
|
json={"name": "Migration project"},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert project_response.status_code == 201, project_response.text
|
|
project_id = project_response.json()["id"]
|
|
global_memory = _save_memory(client, "migration-global", "Global memory", "Global data")
|
|
project_memory_response = client.post(
|
|
"/api/v1/memories",
|
|
json={
|
|
"request_id": "migration-project",
|
|
"scope": "project",
|
|
"project_id": project_id,
|
|
"memory_type": "decision",
|
|
"title": "Project memory",
|
|
"content": "Project data",
|
|
"tags": ["git-test"],
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert project_memory_response.status_code == 201, project_memory_response.text
|
|
project_memory = project_memory_response.json()
|
|
|
|
single = _enable_local_git(client)
|
|
root = client.app.state.settings.memories_dir
|
|
single_head = _git(root, "rev-parse", "HEAD")
|
|
|
|
preview = client.post(
|
|
"/api/v1/git/mode-migration/preview",
|
|
json={"target_mode": "per_workspace"},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert preview.status_code == 200, preview.text
|
|
assert preview.json()["can_migrate"] is True
|
|
migrated = client.post(
|
|
"/api/v1/git/mode-migration/execute",
|
|
json={"target_mode": "per_workspace", "confirmed": True},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert migrated.status_code == 200, migrated.text
|
|
assert not (root / ".git").exists()
|
|
assert (root / "global" / ".git").exists()
|
|
assert (root / "projects" / project_id / ".git").exists()
|
|
assert (root / f"{global_memory['path']}.md").exists()
|
|
assert (root / f"{project_memory['path']}.md").exists()
|
|
|
|
returned = client.post(
|
|
"/api/v1/git/mode-migration/execute",
|
|
json={"target_mode": "single", "confirmed": True},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert returned.status_code == 200, returned.text
|
|
assert (root / ".git").exists()
|
|
assert not (root / "global" / ".git").exists()
|
|
assert not (root / "projects" / project_id / ".git").exists()
|
|
|
|
with client.app.state.session_factory() as db:
|
|
connection = db.scalar(select(GitConnection))
|
|
active = db.scalars(
|
|
select(MemoryRepository).where(MemoryRepository.connection_id == connection.id)
|
|
).all()
|
|
archived = db.scalars(
|
|
select(MemoryRepository).where(MemoryRepository.status == "archived")
|
|
).all()
|
|
assert connection is not None and connection.mode == "single"
|
|
assert len(active) == 1 and active[0].local_path == str(root)
|
|
assert len(archived) == 3
|
|
original = next(item for item in archived if item.id == single["id"])
|
|
assert _git(Path(original.local_path), "rev-parse", single_head) == single_head
|
|
|
|
|
|
def test_mode_migration_failure_restores_repository_and_job_state(
|
|
initialized_client: TestClient,
|
|
) -> None:
|
|
client = initialized_client
|
|
repository = _enable_local_git(client)
|
|
manager = client.app.state.git_manager
|
|
root = client.app.state.settings.memories_dir
|
|
original_head = _git(root, "rev-parse", "HEAD")
|
|
retry_at = datetime.now(UTC) + timedelta(hours=1)
|
|
lease_until = datetime.now(UTC) + timedelta(minutes=5)
|
|
with client.app.state.session_factory() as db:
|
|
record = db.get(MemoryRepository, repository["id"])
|
|
assert record is not None
|
|
record.pending_commits = 1
|
|
db.add(
|
|
GitSyncJob(
|
|
id="migration-rollback-job",
|
|
operation_id="migration-rollback-job",
|
|
repository_id=record.id,
|
|
action="sync",
|
|
summary="Preserve this state",
|
|
status="committing",
|
|
next_retry_at=retry_at,
|
|
lease_owner="original-worker",
|
|
lease_expires_at=lease_until,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
def fail_after_creating_target(repository_id: str) -> None:
|
|
with client.app.state.session_factory() as db:
|
|
record = db.get(MemoryRepository, repository_id)
|
|
assert record is not None
|
|
target_git = Path(record.local_path) / ".git"
|
|
target_git.mkdir(parents=True, exist_ok=True)
|
|
raise RuntimeError("simulated repository initialization failure")
|
|
|
|
with (
|
|
patch.object(manager, "_initialize_repository", side_effect=fail_after_creating_target),
|
|
pytest.raises(RuntimeError, match="simulated repository initialization failure"),
|
|
):
|
|
asyncio.run(
|
|
manager.migrate_mode(
|
|
GitModeMigrationRequest(target_mode="per_workspace", confirmed=True)
|
|
)
|
|
)
|
|
|
|
assert (root / ".git").exists()
|
|
assert _git(root, "rev-parse", "HEAD") == original_head
|
|
assert not (root / "global" / ".git").exists()
|
|
with client.app.state.session_factory() as db:
|
|
connection = db.scalar(select(GitConnection))
|
|
record = db.get(MemoryRepository, repository["id"])
|
|
job = db.get(GitSyncJob, "migration-rollback-job")
|
|
assert connection is not None and connection.mode == "single"
|
|
assert record is not None
|
|
assert record.connection_id == connection.id
|
|
assert record.local_path == str(root)
|
|
assert record.pending_commits == 1
|
|
assert job is not None
|
|
assert job.status == "committing"
|
|
assert job.next_retry_at == retry_at
|
|
assert job.lease_owner == "original-worker"
|
|
assert job.lease_expires_at == lease_until
|
|
|
|
migration_root = client.app.state.settings.git_archive_dir / "mode-migrations"
|
|
assert not migration_root.exists() or not any(migration_root.iterdir())
|
|
|
|
|
|
def test_remote_outage_restart_recovery_and_divergence_stop_automatic_push(
|
|
initialized_client: TestClient,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
client = initialized_client
|
|
fake = DiskBasicMemory(client.app.state.settings.memories_dir)
|
|
client.app.state.basic_memory = fake
|
|
client.app.state.git_manager.basic_memory = fake
|
|
remote = tmp_path / "memory-remote.git"
|
|
subprocess.run(["git", "init", "--bare", str(remote)], check=True, capture_output=True)
|
|
repository = _enable_local_git(client, str(remote))
|
|
_drain_git_jobs(client)
|
|
|
|
offline = tmp_path / "memory-remote.offline"
|
|
remote.rename(offline)
|
|
_save_memory(client, "offline-one", "Offline one", "First offline change")
|
|
assert asyncio.run(client.app.state.git_manager.process_once()) is True
|
|
_save_memory(client, "offline-two", "Offline two", "Second offline change")
|
|
assert asyncio.run(client.app.state.git_manager.process_once()) is True
|
|
with client.app.state.session_factory() as db:
|
|
waiting = db.scalars(
|
|
select(GitSyncJob)
|
|
.where(GitSyncJob.status == "waiting_remote")
|
|
.order_by(GitSyncJob.created_at)
|
|
).all()
|
|
assert len(waiting) == 2
|
|
assert all(item.target_commit for item in waiting)
|
|
waiting[0].status = "pushing"
|
|
waiting[0].lease_owner = "stale-worker"
|
|
waiting[0].lease_expires_at = datetime.now(UTC) - timedelta(seconds=1)
|
|
for item in waiting:
|
|
item.next_retry_at = datetime.now(UTC) - timedelta(seconds=1)
|
|
db.commit()
|
|
|
|
offline.rename(remote)
|
|
restarted = GitManager(
|
|
client.app.state.settings,
|
|
client.app.state.session_factory,
|
|
client.app.state.secret_box,
|
|
client.app.state.vault,
|
|
fake,
|
|
RuntimeLeaseManager(client.app.state.session_factory, "restarted-git-worker"),
|
|
)
|
|
for _ in range(5):
|
|
if not asyncio.run(restarted.process_once()):
|
|
break
|
|
with client.app.state.session_factory() as db:
|
|
jobs = db.scalars(select(GitSyncJob)).all()
|
|
assert all(item.status == "completed" for item in jobs)
|
|
record = db.get(MemoryRepository, repository["id"])
|
|
assert record is not None and record.status == "synced"
|
|
assert record.pending_commits == 0
|
|
|
|
local = client.app.state.settings.memories_dir
|
|
local_head = _git(local, "rev-parse", "HEAD")
|
|
remote_head = subprocess.run(
|
|
["git", "--git-dir", str(remote), "rev-parse", "refs/heads/main"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
assert remote_head == local_head
|
|
assert "Offline one" in (local / "global" / "offline-one.md").read_text(encoding="utf-8")
|
|
assert "Offline two" in (local / "global" / "offline-two.md").read_text(encoding="utf-8")
|
|
|
|
external = tmp_path / "external-writer"
|
|
subprocess.run(
|
|
["git", "clone", "--branch", "main", str(remote), str(external)],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
_git(external, "config", "user.name", "External Writer")
|
|
_git(external, "config", "user.email", "external@example.test")
|
|
(external / "external.md").write_text("remote-only change\n", encoding="utf-8")
|
|
_git(external, "add", "external.md")
|
|
_git(external, "commit", "-m", "feat: external divergence")
|
|
_git(external, "push", "origin", "main")
|
|
|
|
_save_memory(client, "local-divergence", "Local divergence", "Local-only change")
|
|
assert asyncio.run(restarted.process_once()) is True
|
|
with client.app.state.session_factory() as db:
|
|
failed = db.scalar(
|
|
select(GitSyncJob)
|
|
.where(GitSyncJob.status == "failed")
|
|
.order_by(GitSyncJob.created_at.desc())
|
|
)
|
|
record = db.get(MemoryRepository, repository["id"])
|
|
assert failed is not None and "REMOTE_DIVERGED" in (failed.error_message or "")
|
|
assert record is not None and record.status == "remote_diverged"
|
|
assert failed.next_retry_at is None
|
|
remote_after_divergence = subprocess.run(
|
|
["git", "--git-dir", str(remote), "rev-parse", "refs/heads/main"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
assert remote_after_divergence != _git(local, "rev-parse", "HEAD")
|
|
|
|
|
|
def test_two_git_workers_claim_one_job_once(initialized_client: TestClient) -> None:
|
|
client = initialized_client
|
|
repository = _enable_local_git(client)
|
|
root = client.app.state.settings.memories_dir
|
|
(root / "worker-test.md").write_text("one logical change\n", encoding="utf-8")
|
|
with client.app.state.session_factory() as db:
|
|
record = db.get(MemoryRepository, repository["id"])
|
|
assert record is not None
|
|
record.pending_commits = 1
|
|
db.add(
|
|
GitSyncJob(
|
|
id="cross-worker-job",
|
|
operation_id="cross-worker-job",
|
|
repository_id=record.id,
|
|
action="save",
|
|
summary="Cross-worker commit",
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
second = GitManager(
|
|
client.app.state.settings,
|
|
client.app.state.session_factory,
|
|
client.app.state.secret_box,
|
|
client.app.state.vault,
|
|
client.app.state.basic_memory,
|
|
RuntimeLeaseManager(client.app.state.session_factory, "second-git-worker"),
|
|
)
|
|
|
|
async def process_together() -> list[bool]:
|
|
return list(
|
|
await asyncio.gather(
|
|
client.app.state.git_manager.process_once(),
|
|
second.process_once(),
|
|
)
|
|
)
|
|
|
|
results = asyncio.run(process_together())
|
|
assert sum(results) == 1
|
|
with client.app.state.session_factory() as db:
|
|
job = db.get(GitSyncJob, "cross-worker-job")
|
|
record = db.get(MemoryRepository, repository["id"])
|
|
assert job is not None and job.status == "completed"
|
|
assert job.attempts == 1
|
|
assert job.lease_owner is None and job.lease_expires_at is None
|
|
assert record is not None and record.pending_commits == 0
|
|
assert _git(root, "log", "-1", "--pretty=%s") == "save: Cross-worker commit"
|