gabriel / muse public
test_pull_fetch_prune_config.py python
349 lines 16.5 KB
Raw
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