diff --git a/src/rgit/gitutil.py b/src/rgit/gitutil.py index 1405fbd..06ea5b0 100644 --- a/src/rgit/gitutil.py +++ b/src/rgit/gitutil.py @@ -215,8 +215,25 @@ def _tracked_change_disposition( try: linkname = os.readlink(path) except OSError: - linkname = ("" if set(entry["new_sha"]) == {"0"} - else os.fsdecode(_git_blob(repo, entry["new_sha"]))) + if set(entry["new_sha"]) != {"0"}: + linkname = os.fsdecode(_git_blob(repo, entry["new_sha"])) + else: + # With core.symlinks=false, Git checks a symlink out as a + # regular file containing its target. An unstaged edit keeps + # new_mode=120000 and new_sha=zero, so inspect that placeholder + # before deciding it is an ordinary-file replacement. Without + # this check, changing one external target to another would be + # rendered add-only and leak the new host path. + if path.is_symlink() or not path.is_file(): + return "skip", "could not safely inspect symlink target" + try: + with path.open("rb") as f: + content = f.read(MAX_UNTRACKED_DIFF_BYTES + 1) + except OSError: + return "skip", "could not safely inspect symlink target" + if len(content) > MAX_UNTRACKED_DIFF_BYTES: + return "skip", "could not safely inspect symlink target" + linkname = os.fsdecode(content) if linkname and not _symlink_target_within(repo, file, linkname): return "skip", "symlink points outside the repo" old_external = ( @@ -227,7 +244,8 @@ def _tracked_change_disposition( # Including the raw diff would emit the removed symlink target as a `-` # line. If the new side is a real file, capture it add-only instead of # losing the change; otherwise (deletion, or a new in-repo symlink) skip. - if entry["new_mode"] != "120000" and (repo / file).is_file(): + path = repo / file + if not path.is_symlink() and path.is_file(): return "add_only", None return "skip", "symlink points outside the repo" return "include", None diff --git a/tests/test_active_edges.py b/tests/test_active_edges.py index 0c53f01..b45be19 100644 --- a/tests/test_active_edges.py +++ b/tests/test_active_edges.py @@ -1,4 +1,6 @@ # tests/test_active_edges.py +import sys + from rgit.store.store import Store from rgit.store.models import Capsule, CodeSlice from rgit.runner import run_experiment @@ -25,12 +27,15 @@ def test_active_edges_round_trip(git_repo): def test_run_experiment_writes_active_edges(git_repo): store = Store.init(git_repo) a = _cap(store, "a") - run_id, _ = run_experiment(store, ["true"], MockSegmenter([]), now="2026-01-01T00:00:00", - active=[a]) + run_id, _ = run_experiment( + store, [sys.executable, "-c", "pass"], MockSegmenter([]), + now="2026-01-01T00:00:00", active=[a]) assert store.active_features(run_id) == [a] def test_run_experiment_without_active_writes_none(git_repo): store = Store.init(git_repo) - run_id, _ = run_experiment(store, ["true"], MockSegmenter([]), now="2026-01-01T00:00:00") + run_id, _ = run_experiment( + store, [sys.executable, "-c", "pass"], MockSegmenter([]), + now="2026-01-01T00:00:00") assert store.active_features(run_id) == [] diff --git a/tests/test_gitutil.py b/tests/test_gitutil.py index fce1522..ae9ad4b 100644 --- a/tests/test_gitutil.py +++ b/tests/test_gitutil.py @@ -2,6 +2,7 @@ import os import subprocess import tarfile +from pathlib import Path import pytest @@ -41,6 +42,35 @@ def _commit_tracked_symlink_or_skip(repo, rel: str) -> None: cwd=repo, check=True, capture_output=True) +def _commit_symlink_placeholder(repo, rel: str, target: str) -> None: + """Commit a symlink blob while keeping a core.symlinks=false worktree file.""" + subprocess.run(["git", "config", "core.symlinks", "false"], cwd=repo, + check=True, capture_output=True) + sha = subprocess.run( + ["git", "hash-object", "-w", "--stdin"], cwd=repo, input=target, + check=True, capture_output=True, text=True, + ).stdout.strip() + subprocess.run( + ["git", "update-index", "--add", "--cacheinfo", f"120000,{sha},{rel}"], + cwd=repo, check=True, capture_output=True, + ) + subprocess.run(["git", "commit", "-q", "-m", "tracked symlink blob"], + cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "checkout-index", "--force", "--", rel], cwd=repo, + check=True, capture_output=True) + assert not (repo / rel).is_symlink() + assert (repo / rel).read_text() == target + + +def _assert_unstaged_symlink_placeholder_diff(repo, rel: str) -> None: + raw = subprocess.run( + ["git", "diff", "--raw", "--abbrev=40", "HEAD", "--", rel], + cwd=repo, check=True, capture_output=True, text=True, + ).stdout + assert raw.startswith(":120000 120000 ") + assert f"{'0' * 40} M\t{rel}" in raw + + def test_current_commit_returns_sha(git_repo): sha = current_commit(git_repo) assert len(sha) == 40 @@ -187,6 +217,65 @@ def test_diff_since_captures_replaced_external_symlink_without_leaking(git_repo) assert "REPLACED_SECRET_SYMBOL_TOKEN" not in diff +def test_diff_since_captures_windows_symlink_placeholder_replaced_by_file(git_repo): + old_target = str(git_repo.parent / "windows-old-secret-target.py") + _commit_symlink_placeholder(git_repo, "model.py", old_target) + (git_repo / "model.py").write_text("def replacement():\n return 2\n") + _assert_unstaged_symlink_placeholder_diff(git_repo, "model.py") + + diff = diff_since(git_repo, "HEAD") + + assert "def replacement" in diff and "model.py" in diff + assert old_target not in diff + assert "windows-old-secret-target" not in diff + + +@pytest.mark.parametrize("relative_target", [False, True]) +def test_diff_since_skips_changed_windows_external_symlink_placeholder( + git_repo, relative_target): + old_target = str(git_repo.parent / "windows-old-secret-target.py") + new_target = (str(Path("..") / "windows-new-secret-target.py") + if relative_target + else str(git_repo.parent / "windows-new-secret-target.py")) + _commit_symlink_placeholder(git_repo, "model.py", old_target) + (git_repo / "model.py").write_text(new_target) + _assert_unstaged_symlink_placeholder_diff(git_repo, "model.py") + + diff = diff_since(git_repo, "HEAD") + + assert ("research-git: skipped tracked file 'model.py' " + "(symlink points outside the repo)") in diff + assert old_target not in diff + assert new_target not in diff + assert "windows-old-secret-target" not in diff + assert "windows-new-secret-target" not in diff + + +def test_diff_since_fails_closed_when_symlink_placeholder_cannot_be_read( + git_repo, monkeypatch): + old_target = str(git_repo.parent / "windows-old-secret-target.py") + new_target = str(git_repo.parent / "windows-new-secret-target.py") + model = git_repo / "model.py" + _commit_symlink_placeholder(git_repo, "model.py", old_target) + model.write_text(new_target) + _assert_unstaged_symlink_placeholder_diff(git_repo, "model.py") + + original_open = Path.open + + def fail_for_placeholder(path, *args, **kwargs): + if path == model: + raise OSError("simulated sharing violation") + return original_open(path, *args, **kwargs) + + monkeypatch.setattr(Path, "open", fail_for_placeholder) + diff = diff_since(git_repo, "HEAD") + + assert ("research-git: skipped tracked file 'model.py' " + "(could not safely inspect symlink target)") in diff + assert old_target not in diff + assert new_target not in diff + + def test_binary_skip_reason_reports_unreadable_path(): # A path that cannot be opened (here, a directory) must yield a skip reason, # not None -- returning None would let diff_since silently omit the file. diff --git a/tests/test_selfupdate.py b/tests/test_selfupdate.py index eaeafaa..626a87e 100644 --- a/tests/test_selfupdate.py +++ b/tests/test_selfupdate.py @@ -1,5 +1,7 @@ +import os import subprocess import sys +from pathlib import Path from rgit import selfupdate @@ -61,6 +63,24 @@ def fake_run(cmd, **kw): assert ["/usr/bin/rgit", "install", "codex", "--from-update"] in ran +def test_run_update_refresh_falls_back_to_python_module(monkeypatch, capsys): + ran = [] + + def fake_run(cmd, **kw): + ran.append(cmd) + return _completed(0, out="ok") + + monkeypatch.setattr(selfupdate.subprocess, "run", fake_run) + monkeypatch.setattr(selfupdate.shutil, "which", lambda name: None) + import rgit.installer as installer + monkeypatch.setattr(installer, "detect_platforms", lambda: ["codex"]) + + assert selfupdate.run_update() == 0 + + assert [sys.executable, "-m", "rgit", "install", "codex", + "--from-update"] in ran + + def test_run_update_failure_skips_refresh(monkeypatch, capsys): ran = [] @@ -122,7 +142,11 @@ def fake_run(cmd, **kw): def test_python_dash_m_rgit_entrypoint(): + src = Path(__file__).resolve().parents[1] / "src" + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join( + [str(src), *(p for p in [env.get("PYTHONPATH")] if p)]) p = subprocess.run([sys.executable, "-m", "rgit", "install", "--list"], - capture_output=True, text=True) + capture_output=True, text=True, env=env) assert p.returncode == 0 assert "platforms:" in p.stdout