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"