Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/rgit/gitutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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
Expand Down
11 changes: 8 additions & 3 deletions tests/test_active_edges.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) == []
89 changes: 89 additions & 0 deletions tests/test_gitutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import subprocess
import tarfile
from pathlib import Path

import pytest

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
26 changes: 25 additions & 1 deletion tests/test_selfupdate.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import os
import subprocess
import sys
from pathlib import Path

from rgit import selfupdate

Expand Down Expand Up @@ -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 = []

Expand Down Expand Up @@ -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
Loading