gabriel / muse public
test_format_patch_range_shorthand.py python
283 lines 10.8 KB
Raw
sha256:e6186c2a5be8e7a1361b66ad64d74d3ed1865f3e713c55e7931b4c983e3c9df6 fix: get_signing_identity's MUSE_AGENT_KEY_FD path derived … Sonnet 5 patch 6 hours ago
1 """TDD — muse#37: -N shorthand and A..B range semantics for format-patch.
2
3 Also closes the same gap in ``muse rev-list`` (-N shorthand + -n short flag
4 alias). ``muse log`` already had both -N and A..B support before this ticket
5 (verified, not re-tested here beyond a smoke check against regressions).
6 """
7 from __future__ import annotations
8
9 import datetime
10 import json
11 import pathlib
12
13 import pytest
14
15 from tests.cli_test_helper import CliRunner, InvokeResult
16 from muse.core.ids import hash_commit, hash_snapshot
17 from muse.core.commits import CommitRecord, write_commit
18 from muse.core.snapshots import SnapshotRecord, write_snapshot
19 from muse.core.object_store import write_object
20 from muse.core.types import blob_id
21 from muse.core.paths import muse_dir, ref_path
22
23 runner = CliRunner()
24
25
26 # ---------------------------------------------------------------------------
27 # Helpers (mirrors tests/test_cmd_format_patch.py's fixture pattern)
28 # ---------------------------------------------------------------------------
29
30
31 def _init_repo(path: pathlib.Path) -> pathlib.Path:
32 dot_muse = muse_dir(path)
33 for sub in ("commits", "snapshots", "objects", "refs/heads"):
34 (dot_muse / sub).mkdir(parents=True, exist_ok=True)
35 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
36 (dot_muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo", "domain": "code"}))
37 return path
38
39
40 def _write_object(repo: pathlib.Path, content: bytes) -> str:
41 oid = blob_id(content)
42 write_object(repo, oid, content)
43 return oid
44
45
46 def _commit(
47 repo: pathlib.Path,
48 msg: str,
49 manifest: dict[str, str],
50 branch: str = "main",
51 parent: str | None = None,
52 ts: datetime.datetime | None = None,
53 ) -> str:
54 ts = ts or datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
55 sid = hash_snapshot(manifest)
56 write_snapshot(repo, SnapshotRecord(snapshot_id=sid, manifest=manifest, created_at=ts))
57 parent_ids = [parent] if parent else []
58 cid = hash_commit(
59 parent_ids=parent_ids,
60 snapshot_id=sid,
61 message=msg,
62 committed_at_iso=ts.isoformat(),
63 author="gabriel",
64 )
65 write_commit(repo, CommitRecord(
66 commit_id=cid, branch=branch,
67 snapshot_id=sid, message=msg, committed_at=ts,
68 author="gabriel", parent_commit_id=parent, parent2_commit_id=None,
69 ))
70 branch_ref = ref_path(repo, branch)
71 branch_ref.parent.mkdir(parents=True, exist_ok=True)
72 branch_ref.write_text(cid)
73 return cid
74
75
76 def _fp(repo: pathlib.Path, *args: str) -> InvokeResult:
77 return runner.invoke(None, ["format-patch", *args], env={"MUSE_REPO_ROOT": str(repo)})
78
79
80 def _rev_list(repo: pathlib.Path, *args: str) -> InvokeResult:
81 return runner.invoke(None, ["rev-list", *args], env={"MUSE_REPO_ROOT": str(repo)})
82
83
84 @pytest.fixture()
85 def three_commit_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, list[str]]:
86 """Repo with 3 commits on main: c1 (root) -> c2 -> c3 (HEAD)."""
87 repo = _init_repo(tmp_path)
88 oid_a = _write_object(repo, b"content-a-v1\n")
89 c1 = _commit(repo, "c1", {"a.py": oid_a})
90 oid_a2 = _write_object(repo, b"content-a-v2\n")
91 c2 = _commit(repo, "c2", {"a.py": oid_a2}, parent=c1)
92 oid_b = _write_object(repo, b"content-b\n")
93 c3 = _commit(repo, "c3", {"a.py": oid_a2, "b.py": oid_b}, parent=c2)
94 return repo, [c1, c2, c3]
95
96
97 # ---------------------------------------------------------------------------
98 # format-patch: -N shorthand
99 # ---------------------------------------------------------------------------
100
101
102 class TestFormatPatchDashN:
103 def test_dash_1_exports_last_commit_only(
104 self, three_commit_repo: tuple[pathlib.Path, list[str]]
105 ) -> None:
106 repo, (c1, c2, c3) = three_commit_repo
107 result = _fp(repo, "-1", "--json")
108 assert result.exit_code == 0, result.output
109 data = json.loads(result.output)
110 assert data["count"] == 1
111 assert data["patches"][0]["to_commit_id"] == c3
112
113 def test_dash_2_exports_last_two_oldest_first(
114 self, three_commit_repo: tuple[pathlib.Path, list[str]]
115 ) -> None:
116 repo, (c1, c2, c3) = three_commit_repo
117 result = _fp(repo, "-2", "--json")
118 assert result.exit_code == 0, result.output
119 data = json.loads(result.output)
120 assert data["count"] == 2
121 # Oldest first — git's own format-patch numbering convention.
122 assert data["patches"][0]["to_commit_id"] == c2
123 assert data["patches"][1]["to_commit_id"] == c3
124
125 def test_dash_n_exceeding_history_returns_all_available(
126 self, three_commit_repo: tuple[pathlib.Path, list[str]]
127 ) -> None:
128 repo, (c1, c2, c3) = three_commit_repo
129 result = _fp(repo, "-10", "--json")
130 assert result.exit_code == 0, result.output
131 data = json.loads(result.output)
132 assert data["count"] == 3
133
134 def test_dash_zero_is_rejected(
135 self, three_commit_repo: tuple[pathlib.Path, list[str]]
136 ) -> None:
137 repo, _ = three_commit_repo
138 result = _fp(repo, "-0", "--json")
139 assert result.exit_code != 0
140
141 def test_dash_n_output_dir_writes_numbered_files(
142 self, three_commit_repo: tuple[pathlib.Path, list[str]], tmp_path: pathlib.Path
143 ) -> None:
144 repo, (c1, c2, c3) = three_commit_repo
145 out_dir = tmp_path / "patches"
146 out_dir.mkdir()
147 result = _fp(repo, "-2", "--output-dir", str(out_dir))
148 assert result.exit_code == 0, result.output
149 files = sorted(p.name for p in out_dir.iterdir())
150 assert len(files) == 2
151 assert files[0].startswith("0001-")
152 assert files[1].startswith("0002-")
153
154
155 # ---------------------------------------------------------------------------
156 # format-patch: A..B range semantics
157 # ---------------------------------------------------------------------------
158
159
160 class TestFormatPatchRange:
161 def test_range_exports_commits_since_base_exclusive(
162 self, three_commit_repo: tuple[pathlib.Path, list[str]]
163 ) -> None:
164 """This is the exact ticket scenario: passing the base (parent) ref
165 alone exports only that commit, but 'base..HEAD' exports everything
166 since it — c2 and c3, not c1."""
167 repo, (c1, c2, c3) = three_commit_repo
168 result = _fp(repo, f"{c1}..HEAD", "--json")
169 assert result.exit_code == 0, result.output
170 data = json.loads(result.output)
171 assert data["count"] == 2
172 to_ids = [p["to_commit_id"] for p in data["patches"]]
173 assert to_ids == [c2, c3] # oldest first, c1 excluded
174
175 def test_bare_ref_still_exports_only_that_single_commit(
176 self, three_commit_repo: tuple[pathlib.Path, list[str]]
177 ) -> None:
178 """Regression guard for the documented (intentional) divergence from
179 git: muse format-patch <ref> without '..' or '-N' exports ONLY that
180 commit, never a range."""
181 repo, (c1, c2, c3) = three_commit_repo
182 result = _fp(repo, c1, "--json")
183 assert result.exit_code == 0, result.output
184 data = json.loads(result.output)
185 assert "patch_id" in data # single-commit schema, not count/patches
186 assert "count" not in data
187 assert data["to_commit_id"] == c1
188
189 def test_range_with_empty_local_side_rejected(
190 self, three_commit_repo: tuple[pathlib.Path, list[str]]
191 ) -> None:
192 repo, _ = three_commit_repo
193 result = _fp(repo, "..HEAD", "--json")
194 assert result.exit_code != 0
195
196 def test_default_head_unchanged_single_commit_schema(
197 self, three_commit_repo: tuple[pathlib.Path, list[str]]
198 ) -> None:
199 """No args at all: default behavior (HEAD, single commit) is 100%
200 unchanged from before this ticket."""
201 repo, (c1, c2, c3) = three_commit_repo
202 result = _fp(repo, "--json")
203 assert result.exit_code == 0, result.output
204 data = json.loads(result.output)
205 assert data["to_commit_id"] == c3
206 assert "count" not in data
207
208
209 # ---------------------------------------------------------------------------
210 # rev-list: -N shorthand + -n short flag (muse#37 audit sweep)
211 # ---------------------------------------------------------------------------
212
213
214 class TestRevListDashN:
215 def test_dash_1_alone_defaults_to_head(
216 self, three_commit_repo: tuple[pathlib.Path, list[str]]
217 ) -> None:
218 repo, (c1, c2, c3) = three_commit_repo
219 result = _rev_list(repo, "-1", "--json")
220 assert result.exit_code == 0, result.output
221 data = json.loads(result.output)
222 assert data["commit_ids"] == [c3]
223
224 def test_dash_n_with_explicit_ref(
225 self, three_commit_repo: tuple[pathlib.Path, list[str]]
226 ) -> None:
227 repo, (c1, c2, c3) = three_commit_repo
228 result = _rev_list(repo, "-2", "HEAD", "--json")
229 assert result.exit_code == 0, result.output
230 data = json.loads(result.output)
231 assert data["commit_ids"] == [c3, c2] # newest-first (rev-list default)
232
233 def test_dash_n_either_order(
234 self, three_commit_repo: tuple[pathlib.Path, list[str]]
235 ) -> None:
236 repo, (c1, c2, c3) = three_commit_repo
237 result = _rev_list(repo, "HEAD", "-2", "--json")
238 assert result.exit_code == 0, result.output
239 data = json.loads(result.output)
240 assert data["commit_ids"] == [c3, c2]
241
242 def test_short_flag_n_alias_for_max_count(
243 self, three_commit_repo: tuple[pathlib.Path, list[str]]
244 ) -> None:
245 repo, (c1, c2, c3) = three_commit_repo
246 result = _rev_list(repo, "-n", "1", "HEAD", "--json")
247 assert result.exit_code == 0, result.output
248 data = json.loads(result.output)
249 assert data["commit_ids"] == [c3]
250
251 def test_too_many_refs_rejected(
252 self, three_commit_repo: tuple[pathlib.Path, list[str]]
253 ) -> None:
254 repo, (c1, c2, c3) = three_commit_repo
255 result = _rev_list(repo, "HEAD", "main", "--json")
256 assert result.exit_code != 0
257
258 def test_two_dash_n_tokens_rejected(
259 self, three_commit_repo: tuple[pathlib.Path, list[str]]
260 ) -> None:
261 repo, (c1, c2, c3) = three_commit_repo
262 result = _rev_list(repo, "-1", "-2", "--json")
263 assert result.exit_code != 0
264
265 def test_range_syntax_unaffected(
266 self, three_commit_repo: tuple[pathlib.Path, list[str]]
267 ) -> None:
268 """Regression guard: A..B range parsing still works after the ref
269 positional became nargs='*'."""
270 repo, (c1, c2, c3) = three_commit_repo
271 result = _rev_list(repo, f"{c1}..HEAD", "--json")
272 assert result.exit_code == 0, result.output
273 data = json.loads(result.output)
274 assert sorted(data["commit_ids"]) == sorted([c2, c3])
275
276 def test_plain_ref_unaffected(
277 self, three_commit_repo: tuple[pathlib.Path, list[str]]
278 ) -> None:
279 repo, (c1, c2, c3) = three_commit_repo
280 result = _rev_list(repo, "HEAD", "--json")
281 assert result.exit_code == 0, result.output
282 data = json.loads(result.output)
283 assert data["commit_ids"] == [c3, c2, c1]
File History 1 commit
sha256:e6186c2a5be8e7a1361b66ad64d74d3ed1865f3e713c55e7931b4c983e3c9df6 fix: get_signing_identity's MUSE_AGENT_KEY_FD path derived … Sonnet 5 patch 6 hours ago