diff --git a/nanobot/utils/gitstore.py b/nanobot/utils/gitstore.py index 338bbcfb..46199b06 100644 --- a/nanobot/utils/gitstore.py +++ b/nanobot/utils/gitstore.py @@ -168,8 +168,8 @@ class GitStore: # -- internal helpers ------------------------------------------------------ def _staging_paths(self, *paths: str) -> list[str]: - """Return absolute paths so Dulwich resolves them inside this workspace.""" - return [str((self._workspace / path).resolve()) for path in paths] + """Return absolute paths without resolving tracked-file symlinks.""" + return [str((self._workspace / path).absolute()) for path in paths] def _resolve_sha(self, short_sha: str) -> bytes | None: """Resolve a short SHA prefix to the full SHA bytes.""" diff --git a/tests/utils/test_gitstore.py b/tests/utils/test_gitstore.py index dda5e239..5611f3dd 100644 --- a/tests/utils/test_gitstore.py +++ b/tests/utils/test_gitstore.py @@ -2,6 +2,7 @@ import subprocess from datetime import datetime, timedelta, timezone +from pathlib import Path from unittest.mock import patch import pytest @@ -225,20 +226,47 @@ class TestNestedRepoProtection: assert result is True assert (workspace / ".git").is_dir() - def test_staging_paths_are_resolved_from_workspace(self, tmp_path, monkeypatch): + def test_staging_paths_are_absolute_from_workspace(self, tmp_path, monkeypatch): """Git operations should not depend on the process working directory.""" + from dulwich import porcelain + workspace = tmp_path / "workspace" workspace.mkdir() monkeypatch.chdir(tmp_path) git = GitStore(workspace, tracked_files=["MEMORY.md"]) - assert git.init() is True - assert len(git.log()) == 1 + with patch.object(porcelain, "add", wraps=porcelain.add) as mock_add: + assert git.init() is True + assert len(git.log()) == 1 - (workspace / "MEMORY.md").write_text("updated\n", encoding="utf-8") - assert git.auto_commit("update memory") is not None - assert len(git.log()) == 2 + (workspace / "MEMORY.md").write_text("updated\n", encoding="utf-8") + assert git.auto_commit("update memory") is not None + assert len(git.log()) == 2 + + assert len(mock_add.call_args_list) == 2 + for call in mock_add.call_args_list: + staging_paths = [Path(path) for path in call.kwargs["paths"]] + assert all(path.is_absolute() for path in staging_paths) + assert all(path.is_relative_to(workspace) for path in staging_paths) + + def test_staging_paths_preserve_symlinks(self, tmp_path): + """Absolute staging paths should still identify the tracked symlink itself.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + target = tmp_path / "shared-memory.md" + target.write_text("shared\n", encoding="utf-8") + link = workspace / "MEMORY.md" + try: + link.symlink_to(target) + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + git = GitStore(workspace, tracked_files=["MEMORY.md"]) + + staging_path = Path(git._staging_paths("MEMORY.md")[0]) + assert staging_path == link.absolute() + assert staging_path.is_symlink() def test_init_refuses_inside_git_worktree(self, tmp_path): """init() should refuse when the parent checkout is a git worktree."""