test_pull_fetch_prune_config.py
python
sha256:e6186c2a5be8e7a1361b66ad64d74d3ed1865f3e713c55e7931b4c983e3c9df6
fix: get_signing_identity's MUSE_AGENT_KEY_FD path derived …
Sonnet 5
patch
18 hours ago
| 1 | """muse#22 — fetch.prune config option; muse pull prunes stale tracking refs. |
| 2 | |
| 3 | Background: when a merge proposal is merged on the hub with "source branch |
| 4 | deleted", `.muse/remotes/<remote>/<branch>` for that branch was never |
| 5 | cleaned up by a subsequent `muse pull` — only an explicit |
| 6 | `muse fetch <remote> --prune` removed it. `muse fetch --prune` itself |
| 7 | already existed; this suite covers the two things that didn't: |
| 8 | |
| 9 | 1. A `fetch.prune` config switch (repo + global, repo wins) so pruning can |
| 10 | be made the default without passing `--prune` on every invocation, |
| 11 | mirroring git's own `fetch.prune` exactly (the user's own proposed |
| 12 | solution on this ticket). |
| 13 | 2. `muse pull` actually pruning at all — it had its own independent fetch |
| 14 | logic that never called the pruning routine `muse fetch --prune` uses. |
| 15 | """ |
| 16 | from __future__ import annotations |
| 17 | |
| 18 | import datetime |
| 19 | import json |
| 20 | import pathlib |
| 21 | from unittest.mock import MagicMock, patch |
| 22 | |
| 23 | import pytest |
| 24 | |
| 25 | from tests.cli_test_helper import CliRunner, InvokeResult |
| 26 | from muse.core.types import Manifest, blob_id |
| 27 | from muse.core.paths import muse_dir, remotes_dir |
| 28 | |
| 29 | cli = None |
| 30 | runner = CliRunner() |
| 31 | |
| 32 | REMOTE_ID = "a" * 64 |
| 33 | STALE_ID = "b" * 64 |
| 34 | |
| 35 | |
| 36 | def _env(root: pathlib.Path) -> Manifest: |
| 37 | return {"MUSE_REPO_ROOT": str(root)} |
| 38 | |
| 39 | |
| 40 | def _write_remote_ref(tmp_path: pathlib.Path, remote: str, branch: str, commit_id: str) -> None: |
| 41 | ref_file = remotes_dir(tmp_path) / remote / branch |
| 42 | ref_file.parent.mkdir(parents=True, exist_ok=True) |
| 43 | ref_file.write_text(commit_id) |
| 44 | |
| 45 | |
| 46 | # --------------------------------------------------------------------------- |
| 47 | # Unit — get_fetch_prune precedence |
| 48 | # --------------------------------------------------------------------------- |
| 49 | |
| 50 | |
| 51 | class TestGetFetchPrunePrecedence: |
| 52 | def test_default_false_when_unset(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| 53 | from muse.cli.config import get_fetch_prune |
| 54 | fake_home = tmp_path / "fakehome" |
| 55 | fake_home.mkdir() |
| 56 | monkeypatch.setattr(pathlib.Path, "home", lambda: fake_home) |
| 57 | repo = tmp_path / "repo" |
| 58 | repo.mkdir() |
| 59 | assert get_fetch_prune(repo) is False |
| 60 | |
| 61 | def test_repo_true_wins(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| 62 | from muse.cli.config import get_fetch_prune, set_config_value |
| 63 | fake_home = tmp_path / "fakehome" |
| 64 | fake_home.mkdir() |
| 65 | monkeypatch.setattr(pathlib.Path, "home", lambda: fake_home) |
| 66 | repo = tmp_path / "repo" |
| 67 | repo.mkdir() |
| 68 | set_config_value("fetch.prune", "true", repo) |
| 69 | assert get_fetch_prune(repo) is True |
| 70 | |
| 71 | def test_global_true_used_when_repo_unset(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| 72 | from muse.cli.config import get_fetch_prune, set_config_value |
| 73 | fake_home = tmp_path / "fakehome" |
| 74 | fake_home.mkdir() |
| 75 | monkeypatch.setattr(pathlib.Path, "home", lambda: fake_home) |
| 76 | set_config_value("fetch.prune", "true", fake_home) |
| 77 | repo = tmp_path / "repo" |
| 78 | repo.mkdir() |
| 79 | assert get_fetch_prune(repo) is True |
| 80 | |
| 81 | def test_repo_false_overrides_global_true(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| 82 | from muse.cli.config import get_fetch_prune, set_config_value |
| 83 | fake_home = tmp_path / "fakehome" |
| 84 | fake_home.mkdir() |
| 85 | monkeypatch.setattr(pathlib.Path, "home", lambda: fake_home) |
| 86 | set_config_value("fetch.prune", "true", fake_home) |
| 87 | repo = tmp_path / "repo" |
| 88 | repo.mkdir() |
| 89 | set_config_value("fetch.prune", "false", repo) |
| 90 | assert get_fetch_prune(repo) is False |
| 91 | |
| 92 | |
| 93 | # --------------------------------------------------------------------------- |
| 94 | # Integration — muse config set/get --global |
| 95 | # --------------------------------------------------------------------------- |
| 96 | |
| 97 | |
| 98 | class TestConfigGlobalFlag: |
| 99 | def test_set_global_writes_to_home_not_repo( |
| 100 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 101 | ) -> None: |
| 102 | fake_home = tmp_path / "fakehome" |
| 103 | fake_home.mkdir() |
| 104 | repo = tmp_path / "repo" |
| 105 | repo.mkdir() |
| 106 | monkeypatch.setenv("MUSE_REPO_ROOT", str(repo)) |
| 107 | monkeypatch.chdir(repo) |
| 108 | monkeypatch.setattr(pathlib.Path, "home", lambda: fake_home) |
| 109 | |
| 110 | r = runner.invoke(cli, ["config", "set", "--global", "fetch.prune", "true", "--json"], env=_env(repo)) |
| 111 | assert r.exit_code == 0, r.output |
| 112 | |
| 113 | assert not (repo / ".muse" / "config.toml").exists(), ( |
| 114 | "--global must not write to the repo config" |
| 115 | ) |
| 116 | assert (fake_home / ".muse" / "config.toml").exists() |
| 117 | assert "prune = true" in (fake_home / ".muse" / "config.toml").read_text() |
| 118 | |
| 119 | def test_get_global_reads_from_home( |
| 120 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 121 | ) -> None: |
| 122 | from muse.cli.config import set_config_value |
| 123 | fake_home = tmp_path / "fakehome" |
| 124 | fake_home.mkdir() |
| 125 | repo = tmp_path / "repo" |
| 126 | repo.mkdir() |
| 127 | monkeypatch.setenv("MUSE_REPO_ROOT", str(repo)) |
| 128 | monkeypatch.chdir(repo) |
| 129 | monkeypatch.setattr(pathlib.Path, "home", lambda: fake_home) |
| 130 | set_config_value("fetch.prune", "true", fake_home) |
| 131 | |
| 132 | r = runner.invoke(cli, ["config", "get", "--global", "fetch.prune", "--json"], env=_env(repo)) |
| 133 | assert r.exit_code == 0, r.output |
| 134 | data = json.loads(r.output) |
| 135 | assert data["value"] == "true" |
| 136 | |
| 137 | def test_repo_set_does_not_leak_to_global_get( |
| 138 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 139 | ) -> None: |
| 140 | fake_home = tmp_path / "fakehome" |
| 141 | fake_home.mkdir() |
| 142 | repo = tmp_path / "repo" |
| 143 | repo.mkdir() |
| 144 | monkeypatch.setenv("MUSE_REPO_ROOT", str(repo)) |
| 145 | monkeypatch.chdir(repo) |
| 146 | monkeypatch.setattr(pathlib.Path, "home", lambda: fake_home) |
| 147 | |
| 148 | r = runner.invoke(cli, ["config", "set", "fetch.prune", "true", "--json"], env=_env(repo)) |
| 149 | assert r.exit_code == 0, r.output |
| 150 | |
| 151 | r = runner.invoke(cli, ["config", "get", "--global", "fetch.prune", "--json"], env=_env(repo)) |
| 152 | assert r.exit_code != 0, "repo-level set must not be visible via --global get" |
| 153 | |
| 154 | |
| 155 | # --------------------------------------------------------------------------- |
| 156 | # Integration — muse fetch honours fetch.prune config |
| 157 | # --------------------------------------------------------------------------- |
| 158 | |
| 159 | |
| 160 | def _init_fetch_repo(tmp_path: pathlib.Path) -> None: |
| 161 | dot_muse = muse_dir(tmp_path) |
| 162 | for sub in ("objects", "commits", "snapshots", "remotes", "refs/heads"): |
| 163 | (dot_muse / sub).mkdir(parents=True, exist_ok=True) |
| 164 | (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") |
| 165 | (dot_muse / "refs" / "heads" / "main").write_text("") |
| 166 | (dot_muse / "config.toml").write_text('[remotes.origin]\nurl = "http://localhost:19999"\n') |
| 167 | (dot_muse / "repo.json").write_text('{"id": "test-repo-id"}') |
| 168 | |
| 169 | |
| 170 | def _make_remote_info(branch_heads: Manifest) -> dict: |
| 171 | return { |
| 172 | "repo_id": "test-repo-id", |
| 173 | "domain": "code", |
| 174 | "default_branch": "main", |
| 175 | "branch_heads": branch_heads, |
| 176 | } |
| 177 | |
| 178 | |
| 179 | class TestFetchHonoursPruneConfig: |
| 180 | def test_config_true_prunes_without_explicit_flag( |
| 181 | self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] |
| 182 | ) -> None: |
| 183 | from muse.cli.config import set_config_value |
| 184 | _init_fetch_repo(tmp_path) |
| 185 | _write_remote_ref(tmp_path, "origin", "dead-branch", STALE_ID) |
| 186 | set_config_value("fetch.prune", "true", tmp_path) |
| 187 | |
| 188 | info = _make_remote_info({"main": REMOTE_ID}) |
| 189 | with patch("muse.cli.commands.fetch.get_remote", return_value="http://localhost:19999"): |
| 190 | with patch("muse.cli.commands.fetch.get_signing_identity", return_value=None): |
| 191 | with patch("muse.cli.commands.fetch.make_transport") as mt: |
| 192 | mt.return_value.fetch_remote_info.return_value = info |
| 193 | mt.return_value.fetch_mpack.return_value = { |
| 194 | "repo_id": "test-repo-id", "domain": "code", "default_branch": "main", |
| 195 | "branch_heads": {"main": REMOTE_ID}, "commits": [], "snapshots": [], |
| 196 | "blobs": [], "blobs_received": 0, "shallow_commits": [], |
| 197 | } |
| 198 | r = runner.invoke(cli, ["fetch", "origin", "--json"], env=_env(tmp_path)) |
| 199 | assert r.exit_code == 0, r.output |
| 200 | data = json.loads([l for l in r.output.splitlines() if l.strip().startswith("{")][-1]) |
| 201 | assert data["results"][0]["pruned"] == ["origin/dead-branch"], data |
| 202 | |
| 203 | def test_no_prune_flag_overrides_config_true( |
| 204 | self, tmp_path: pathlib.Path |
| 205 | ) -> None: |
| 206 | from muse.cli.config import set_config_value |
| 207 | _init_fetch_repo(tmp_path) |
| 208 | _write_remote_ref(tmp_path, "origin", "dead-branch", STALE_ID) |
| 209 | set_config_value("fetch.prune", "true", tmp_path) |
| 210 | |
| 211 | info = _make_remote_info({"main": REMOTE_ID}) |
| 212 | with patch("muse.cli.commands.fetch.get_remote", return_value="http://localhost:19999"): |
| 213 | with patch("muse.cli.commands.fetch.get_signing_identity", return_value=None): |
| 214 | with patch("muse.cli.commands.fetch.make_transport") as mt: |
| 215 | mt.return_value.fetch_remote_info.return_value = info |
| 216 | mt.return_value.fetch_mpack.return_value = { |
| 217 | "repo_id": "test-repo-id", "domain": "code", "default_branch": "main", |
| 218 | "branch_heads": {"main": REMOTE_ID}, "commits": [], "snapshots": [], |
| 219 | "blobs": [], "blobs_received": 0, "shallow_commits": [], |
| 220 | } |
| 221 | r = runner.invoke(cli, ["fetch", "origin", "--no-prune", "--json"], env=_env(tmp_path)) |
| 222 | assert r.exit_code == 0, r.output |
| 223 | data = json.loads([l for l in r.output.splitlines() if l.strip().startswith("{")][-1]) |
| 224 | assert data["results"][0]["pruned"] == [] |
| 225 | assert (remotes_dir(tmp_path) / "origin" / "dead-branch").exists() |
| 226 | |
| 227 | |
| 228 | # --------------------------------------------------------------------------- |
| 229 | # Integration — muse pull now prunes (it previously never did at all) |
| 230 | # --------------------------------------------------------------------------- |
| 231 | |
| 232 | |
| 233 | @pytest.fixture() |
| 234 | def pull_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: |
| 235 | """Minimal .muse/ repo with one commit on main, ready for muse pull.""" |
| 236 | from muse.core.object_store import write_object |
| 237 | from muse.core.ids import hash_commit, hash_snapshot |
| 238 | from muse.core.commits import CommitRecord, write_commit |
| 239 | from muse.core.snapshots import SnapshotRecord, write_snapshot |
| 240 | |
| 241 | dot_muse = muse_dir(tmp_path) |
| 242 | for sub in ("refs/heads", "objects", "commits", "snapshots", "remotes"): |
| 243 | (dot_muse / sub).mkdir(parents=True, exist_ok=True) |
| 244 | (dot_muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo", "domain": "code"})) |
| 245 | (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") |
| 246 | (dot_muse / "config.toml").write_text('[remotes.origin]\nurl = "https://hub.example.com/r"\n') |
| 247 | |
| 248 | blob = b"x = 1\n" |
| 249 | oid = blob_id(blob) |
| 250 | write_object(tmp_path, oid, blob) |
| 251 | snap_id = hash_snapshot({"a.py": oid}) |
| 252 | write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest={"a.py": oid})) |
| 253 | ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) |
| 254 | cid = hash_commit(parent_ids=[], snapshot_id=snap_id, message="base", committed_at_iso=ts.isoformat()) |
| 255 | write_commit(tmp_path, CommitRecord( |
| 256 | commit_id=cid, branch="main", snapshot_id=snap_id, message="base", committed_at=ts, |
| 257 | )) |
| 258 | (dot_muse / "refs" / "heads" / "main").write_text(cid) |
| 259 | |
| 260 | monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) |
| 261 | monkeypatch.chdir(tmp_path) |
| 262 | return tmp_path |
| 263 | |
| 264 | |
| 265 | class TestPullPrunesStaleRefs: |
| 266 | def test_pull_up_to_date_prunes_when_explicit_flag(self, pull_repo: pathlib.Path) -> None: |
| 267 | """The exact muse#22 repro: pulling a branch that's already up to |
| 268 | date must still prune a DIFFERENT branch's stale tracking ref.""" |
| 269 | from muse.cli.commands.pull import _PullJson # noqa: F401 - import smoke |
| 270 | cid = (muse_dir(pull_repo) / "refs" / "heads" / "main").read_text().strip() |
| 271 | _write_remote_ref(pull_repo, "origin", "fix/proposal-closed-state-badge", STALE_ID) |
| 272 | |
| 273 | info = { |
| 274 | "repo_id": "test-repo", "domain": "code", "default_branch": "main", |
| 275 | "branch_heads": {"main": cid}, |
| 276 | } |
| 277 | with patch("muse.cli.commands.pull.get_remote", return_value="https://hub.example.com/r"): |
| 278 | with patch("muse.cli.commands.pull.get_signing_identity", return_value=None): |
| 279 | with patch("muse.cli.commands.pull.make_transport") as mt: |
| 280 | mt.return_value.fetch_remote_info.return_value = info |
| 281 | r = runner.invoke( |
| 282 | cli, ["pull", "origin", "--prune", "--json"], env=_env(pull_repo), |
| 283 | ) |
| 284 | assert r.exit_code == 0, r.output |
| 285 | data = json.loads([l for l in r.output.splitlines() if l.strip().startswith("{")][-1]) |
| 286 | assert data["status"] == "up_to_date" |
| 287 | assert data["pruned"] == ["origin/fix/proposal-closed-state-badge"] |
| 288 | assert not (remotes_dir(pull_repo) / "origin" / "fix/proposal-closed-state-badge").exists() |
| 289 | |
| 290 | def test_pull_prunes_when_config_true_no_explicit_flag(self, pull_repo: pathlib.Path) -> None: |
| 291 | from muse.cli.config import set_config_value |
| 292 | set_config_value("fetch.prune", "true", pull_repo) |
| 293 | cid = (muse_dir(pull_repo) / "refs" / "heads" / "main").read_text().strip() |
| 294 | _write_remote_ref(pull_repo, "origin", "stale-branch", STALE_ID) |
| 295 | |
| 296 | info = { |
| 297 | "repo_id": "test-repo", "domain": "code", "default_branch": "main", |
| 298 | "branch_heads": {"main": cid}, |
| 299 | } |
| 300 | with patch("muse.cli.commands.pull.get_remote", return_value="https://hub.example.com/r"): |
| 301 | with patch("muse.cli.commands.pull.get_signing_identity", return_value=None): |
| 302 | with patch("muse.cli.commands.pull.make_transport") as mt: |
| 303 | mt.return_value.fetch_remote_info.return_value = info |
| 304 | r = runner.invoke(cli, ["pull", "origin", "--json"], env=_env(pull_repo)) |
| 305 | assert r.exit_code == 0, r.output |
| 306 | data = json.loads([l for l in r.output.splitlines() if l.strip().startswith("{")][-1]) |
| 307 | assert data["pruned"] == ["origin/stale-branch"] |
| 308 | |
| 309 | def test_pull_does_not_prune_by_default(self, pull_repo: pathlib.Path) -> None: |
| 310 | cid = (muse_dir(pull_repo) / "refs" / "heads" / "main").read_text().strip() |
| 311 | _write_remote_ref(pull_repo, "origin", "stale-branch", STALE_ID) |
| 312 | |
| 313 | info = { |
| 314 | "repo_id": "test-repo", "domain": "code", "default_branch": "main", |
| 315 | "branch_heads": {"main": cid}, |
| 316 | } |
| 317 | with patch("muse.cli.commands.pull.get_remote", return_value="https://hub.example.com/r"): |
| 318 | with patch("muse.cli.commands.pull.get_signing_identity", return_value=None): |
| 319 | with patch("muse.cli.commands.pull.make_transport") as mt: |
| 320 | mt.return_value.fetch_remote_info.return_value = info |
| 321 | r = runner.invoke(cli, ["pull", "origin", "--json"], env=_env(pull_repo)) |
| 322 | assert r.exit_code == 0, r.output |
| 323 | data = json.loads([l for l in r.output.splitlines() if l.strip().startswith("{")][-1]) |
| 324 | assert data["pruned"] == [] |
| 325 | assert (remotes_dir(pull_repo) / "origin" / "stale-branch").exists() |
| 326 | |
| 327 | def test_pull_dry_run_prune_does_not_delete(self, pull_repo: pathlib.Path) -> None: |
| 328 | cid = (muse_dir(pull_repo) / "refs" / "heads" / "main").read_text().strip() |
| 329 | _write_remote_ref(pull_repo, "origin", "stale-branch", STALE_ID) |
| 330 | |
| 331 | info = { |
| 332 | "repo_id": "test-repo", "domain": "code", "default_branch": "main", |
| 333 | "branch_heads": {"main": cid}, |
| 334 | } |
| 335 | with patch("muse.cli.commands.pull.get_remote", return_value="https://hub.example.com/r"): |
| 336 | with patch("muse.cli.commands.pull.get_signing_identity", return_value=None): |
| 337 | with patch("muse.cli.commands.pull.make_transport") as mt: |
| 338 | mt.return_value.fetch_remote_info.return_value = info |
| 339 | r = runner.invoke( |
| 340 | cli, ["pull", "origin", "--prune", "--dry-run", "--json"], env=_env(pull_repo), |
| 341 | ) |
| 342 | assert r.exit_code == 0, r.output |
| 343 | data = json.loads([l for l in r.output.splitlines() if l.strip().startswith("{")][-1]) |
| 344 | assert data["pruned"] == ["origin/stale-branch"], ( |
| 345 | "dry-run must still report what WOULD be pruned" |
| 346 | ) |
| 347 | assert (remotes_dir(pull_repo) / "origin" / "stale-branch").exists(), ( |
| 348 | "--dry-run must not actually delete the ref" |
| 349 | ) |
File History
1 commit
sha256:e6186c2a5be8e7a1361b66ad64d74d3ed1865f3e713c55e7931b4c983e3c9df6
fix: get_signing_identity's MUSE_AGENT_KEY_FD path derived …
Sonnet 5
patch
18 hours ago