nanobot/tests/agent/test_git_store.py
chengyongru b2598270bf
feat(memory): add git-backed version control for dream memory files (#2753)
- Add GitStore class wrapping dulwich for memory file versioning
- Auto-commit memory changes during Dream consolidation
- Add /dream-log and /dream-restore commands for history browsing
- Pass tracked_files as constructor param, generate .gitignore dynamically
2026-04-02 23:52:13 +08:00

226 lines
7.4 KiB
Python

"""Tests for GitStore — git-backed version control for memory files."""
import pytest
from pathlib import Path
from nanobot.agent.git_store import GitStore, CommitInfo
TRACKED = ["SOUL.md", "USER.md", "memory/MEMORY.md"]
@pytest.fixture
def git(tmp_path):
"""Uninitialized GitStore."""
return GitStore(tmp_path, tracked_files=TRACKED)
@pytest.fixture
def git_ready(git):
"""Initialized GitStore with one initial commit."""
git.init()
return git
class TestInit:
def test_not_initialized_by_default(self, git, tmp_path):
assert not git.is_initialized()
assert not (tmp_path / ".git").is_dir()
def test_init_creates_git_dir(self, git, tmp_path):
assert git.init()
assert (tmp_path / ".git").is_dir()
def test_init_idempotent(self, git_ready):
assert not git_ready.init()
def test_init_creates_gitignore(self, git_ready):
gi = git_ready._workspace / ".gitignore"
assert gi.exists()
content = gi.read_text(encoding="utf-8")
for f in TRACKED:
assert f"!{f}" in content
def test_init_touches_tracked_files(self, git_ready):
for f in TRACKED:
assert (git_ready._workspace / f).exists()
def test_init_makes_initial_commit(self, git_ready):
commits = git_ready.log()
assert len(commits) == 1
assert "init" in commits[0].message
class TestBuildGitignore:
def test_subdirectory_dirs(self, git):
content = git._build_gitignore()
assert "!memory/\n" in content
for f in TRACKED:
assert f"!{f}\n" in content
assert content.startswith("/*\n")
def test_root_level_files_no_dir_entries(self, tmp_path):
gs = GitStore(tmp_path, tracked_files=["a.md", "b.md"])
content = gs._build_gitignore()
assert "!a.md\n" in content
assert "!b.md\n" in content
dir_lines = [l for l in content.split("\n") if l.startswith("!") and l.endswith("/")]
assert dir_lines == []
class TestAutoCommit:
def test_returns_none_when_not_initialized(self, git):
assert git.auto_commit("test") is None
def test_commits_file_change(self, git_ready):
(git_ready._workspace / "SOUL.md").write_text("updated", encoding="utf-8")
sha = git_ready.auto_commit("update soul")
assert sha is not None
assert len(sha) == 8
def test_returns_none_when_no_changes(self, git_ready):
assert git_ready.auto_commit("no change") is None
def test_commit_appears_in_log(self, git_ready):
ws = git_ready._workspace
(ws / "SOUL.md").write_text("v2", encoding="utf-8")
sha = git_ready.auto_commit("update soul")
commits = git_ready.log()
assert len(commits) == 2
assert commits[0].sha == sha
def test_does_not_create_empty_commits(self, git_ready):
git_ready.auto_commit("nothing 1")
git_ready.auto_commit("nothing 2")
assert len(git_ready.log()) == 1 # only init commit
class TestLog:
def test_empty_when_not_initialized(self, git):
assert git.log() == []
def test_newest_first(self, git_ready):
ws = git_ready._workspace
for i in range(3):
(ws / "SOUL.md").write_text(f"v{i}", encoding="utf-8")
git_ready.auto_commit(f"commit {i}")
commits = git_ready.log()
assert len(commits) == 4 # init + 3
assert "commit 2" in commits[0].message
assert "init" in commits[-1].message
def test_max_entries(self, git_ready):
ws = git_ready._workspace
for i in range(10):
(ws / "SOUL.md").write_text(f"v{i}", encoding="utf-8")
git_ready.auto_commit(f"c{i}")
assert len(git_ready.log(max_entries=3)) == 3
def test_commit_info_fields(self, git_ready):
c = git_ready.log()[0]
assert isinstance(c, CommitInfo)
assert len(c.sha) == 8
assert c.timestamp
assert c.message
class TestDiffCommits:
def test_empty_when_not_initialized(self, git):
assert git.diff_commits("a", "b") == ""
def test_diff_between_two_commits(self, git_ready):
ws = git_ready._workspace
(ws / "SOUL.md").write_text("original", encoding="utf-8")
git_ready.auto_commit("v1")
(ws / "SOUL.md").write_text("modified", encoding="utf-8")
git_ready.auto_commit("v2")
commits = git_ready.log()
diff = git_ready.diff_commits(commits[1].sha, commits[0].sha)
assert "modified" in diff
def test_invalid_sha_returns_empty(self, git_ready):
assert git_ready.diff_commits("deadbeef", "cafebabe") == ""
class TestFindCommit:
def test_finds_by_prefix(self, git_ready):
ws = git_ready._workspace
(ws / "SOUL.md").write_text("v2", encoding="utf-8")
sha = git_ready.auto_commit("v2")
found = git_ready.find_commit(sha[:4])
assert found is not None
assert found.sha == sha
def test_returns_none_for_unknown(self, git_ready):
assert git_ready.find_commit("deadbeef") is None
class TestShowCommitDiff:
def test_returns_commit_with_diff(self, git_ready):
ws = git_ready._workspace
(ws / "SOUL.md").write_text("content", encoding="utf-8")
sha = git_ready.auto_commit("add content")
result = git_ready.show_commit_diff(sha)
assert result is not None
commit, diff = result
assert commit.sha == sha
assert "content" in diff
def test_first_commit_has_empty_diff(self, git_ready):
init_sha = git_ready.log()[-1].sha
result = git_ready.show_commit_diff(init_sha)
assert result is not None
_, diff = result
assert diff == ""
def test_returns_none_for_unknown(self, git_ready):
assert git_ready.show_commit_diff("deadbeef") is None
class TestCommitInfoFormat:
def test_format_with_diff(self):
from nanobot.agent.git_store import CommitInfo
c = CommitInfo(sha="abcd1234", message="test commit\nsecond line", timestamp="2026-04-02 12:00")
result = c.format(diff="some diff")
assert "test commit" in result
assert "`abcd1234`" in result
assert "some diff" in result
def test_format_without_diff(self):
from nanobot.agent.git_store import CommitInfo
c = CommitInfo(sha="abcd1234", message="test", timestamp="2026-04-02 12:00")
result = c.format()
assert "(no file changes)" in result
class TestRevert:
def test_returns_none_when_not_initialized(self, git):
assert git.revert("abc") is None
def test_reverts_file_content(self, git_ready):
ws = git_ready._workspace
(ws / "SOUL.md").write_text("v2 content", encoding="utf-8")
git_ready.auto_commit("v2")
commits = git_ready.log()
new_sha = git_ready.revert(commits[1].sha) # revert to init
assert new_sha is not None
assert (ws / "SOUL.md").read_text(encoding="utf-8") == ""
def test_invalid_sha_returns_none(self, git_ready):
assert git_ready.revert("deadbeef") is None
class TestMemoryStoreGitProperty:
def test_git_property_exposes_gitstore(self, tmp_path):
from nanobot.agent.memory import MemoryStore
store = MemoryStore(tmp_path)
assert isinstance(store.git, GitStore)
def test_git_property_is_same_object(self, tmp_path):
from nanobot.agent.memory import MemoryStore
store = MemoryStore(tmp_path)
assert store.git is store._git