test_format_patch_range_shorthand.py
python
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