"""Tests for GitStore — line_ages() and core git operations.""" import subprocess import time from datetime import datetime, timedelta, timezone from unittest.mock import patch import pytest from nanobot.utils.gitstore import GitStore @pytest.fixture def git(tmp_path): """Create an initialized GitStore with tracked MEMORY.md.""" g = GitStore(tmp_path, tracked_files=["MEMORY.md", "SOUL.md"]) g.init() return g class TestLineAges: def test_returns_empty_when_not_initialized(self, tmp_path): """line_ages should return [] if the git repo is not initialized.""" git = GitStore(tmp_path, tracked_files=["MEMORY.md"]) assert git.line_ages("MEMORY.md") == [] def test_returns_empty_for_missing_file(self, git): """line_ages should return [] for a file that doesn't exist.""" assert git.line_ages("SOUL.md") == [] def test_returns_empty_for_empty_file(self, git, tmp_path): """line_ages should return [] for an empty tracked file.""" (tmp_path / "SOUL.md").write_text("", encoding="utf-8") git.auto_commit("empty soul") assert git.line_ages("SOUL.md") == [] def test_one_age_per_line(self, git, tmp_path): """line_ages should return one entry per line in the file.""" content = "# Memory\n\n## Section A\n- item 1\n" (tmp_path / "MEMORY.md").write_text(content, encoding="utf-8") git.auto_commit("initial") ages = git.line_ages("MEMORY.md") assert len(ages) == len(content.splitlines()) def test_fresh_lines_have_age_zero(self, git, tmp_path): """Lines committed today should have age_days=0.""" (tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8") git.auto_commit("initial") ages = git.line_ages("MEMORY.md") assert all(a.age_days == 0 for a in ages) def test_age_differentiates_across_days(self, git, tmp_path): """Lines committed today should show correct age when 'now' is mocked forward.""" (tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8") git.auto_commit("initial") future_now = datetime.now(tz=timezone.utc) + timedelta(days=30) with patch("nanobot.utils.gitstore.datetime") as mock_dt: mock_dt.now.return_value = future_now mock_dt.fromtimestamp = datetime.fromtimestamp ages = git.line_ages("MEMORY.md") assert len(ages) == 2 assert all(a.age_days == 30 for a in ages) def test_annotate_failure_returns_empty(self, tmp_path): """If annotate fails, line_ages should return [] gracefully.""" git = GitStore(tmp_path, tracked_files=["MEMORY.md"]) # Don't init — annotate will fail assert git.line_ages("MEMORY.md") == [] def test_partial_edit_only_updates_changed_lines(self, git, tmp_path): """Only modified lines should reflect the new commit's timestamp.""" (tmp_path / "MEMORY.md").write_text( "# Memory\n\n## A\n- old\n\n## B\n- keep\n", encoding="utf-8" ) git.auto_commit("commit1") time.sleep(1.1) # Only modify section A (tmp_path / "MEMORY.md").write_text( "# Memory\n\n## A\n- new\n\n## B\n- keep\n", encoding="utf-8" ) git.auto_commit("commit2") ages = git.line_ages("MEMORY.md") lines = (tmp_path / "MEMORY.md").read_text(encoding="utf-8").splitlines() # All lines are from today, but verify line-level tracking works assert len(ages) == len(lines) # "- new" line and "- keep" line both age=0 (same day), but # the key point is we get per-line results assert len(ages) == 7 class TestNestedRepoProtection: """Regression tests for GitHub issue #2980: nested repo protection.""" def test_init_refuses_inside_git_repo(self, tmp_path): """init() should detect it's inside an existing git repo and refuse.""" project = tmp_path / "project" project.mkdir() (project / ".git").mkdir() workspace = project / "workspace" workspace.mkdir() g = GitStore(workspace, tracked_files=["MEMORY.md"]) result = g.init() assert result is False assert not (workspace / ".git").is_dir() def test_init_preserves_existing_gitignore(self, tmp_path): """init() should preserve existing .gitignore entries and append new ones.""" workspace = tmp_path / "workspace" workspace.mkdir() existing = "*.pyc\n__pycache__/\n" (workspace / ".gitignore").write_text(existing, encoding="utf-8") g = GitStore(workspace, tracked_files=["MEMORY.md"]) result = g.init() assert result is True gitignore = (workspace / ".gitignore").read_text(encoding="utf-8") assert "*.pyc" in gitignore assert "__pycache__/" in gitignore assert "!MEMORY.md" in gitignore assert "!.gitignore" in gitignore def test_init_no_gitignore_creates_new(self, tmp_path): """init() should create .gitignore with Dream content when none exists.""" workspace = tmp_path / "workspace" workspace.mkdir() g = GitStore(workspace, tracked_files=["MEMORY.md"]) result = g.init() assert result is True gitignore = (workspace / ".gitignore").read_text(encoding="utf-8") expected = g._build_gitignore() assert gitignore == expected def test_init_gitignore_merge_idempotent(self, tmp_path): """init() should not duplicate Dream entries already in .gitignore.""" workspace = tmp_path / "workspace" workspace.mkdir() # Pre-existing .gitignore that already has some Dream entries existing = "*.pyc\n/*\n!MEMORY.md\n" (workspace / ".gitignore").write_text(existing, encoding="utf-8") g = GitStore(workspace, tracked_files=["MEMORY.md"]) result = g.init() assert result is True gitignore = (workspace / ".gitignore").read_text(encoding="utf-8") # No duplicate lines lines = gitignore.splitlines() assert lines.count("/*") == 1 assert lines.count("!MEMORY.md") == 1 # Existing entry preserved, new Dream entries appended assert "*.pyc" in gitignore assert "!.gitignore" in gitignore def test_init_outside_git_repo_works_normally(self, tmp_path): """init() should succeed and create .git when not inside a git repo.""" workspace = tmp_path / "workspace" workspace.mkdir() g = GitStore(workspace, tracked_files=["MEMORY.md"]) result = g.init() assert result is True assert (workspace / ".git").is_dir() def test_init_refuses_inside_git_worktree(self, tmp_path): """init() should refuse when the parent checkout is a git worktree.""" repo = tmp_path / "repo" repo.mkdir() subprocess.run(["git", "init", "-q", str(repo)], check=True) (repo / "README.md").write_text("x\n", encoding="utf-8") subprocess.run(["git", "-C", str(repo), "add", "README.md"], check=True) subprocess.run( [ "git", "-C", str(repo), "-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-q", "-m", "init", ], check=True, ) subprocess.run(["git", "-C", str(repo), "branch", "wt-branch"], check=True) worktree = tmp_path / "worktree" subprocess.run( ["git", "-C", str(repo), "worktree", "add", "-q", str(worktree), "wt-branch"], check=True, ) assert (worktree / ".git").is_file() workspace = worktree / "workspace" workspace.mkdir() g = GitStore(workspace, tracked_files=["MEMORY.md"]) result = g.init() assert result is False assert not (workspace / ".git").exists()