"""TDD — muse#37: -N shorthand and A..B range semantics for format-patch. Also closes the same gap in ``muse rev-list`` (-N shorthand + -n short flag alias). ``muse log`` already had both -N and A..B support before this ticket (verified, not re-tested here beyond a smoke check against regressions). """ from __future__ import annotations import datetime import json import pathlib import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.core.ids import hash_commit, hash_snapshot from muse.core.commits import CommitRecord, write_commit from muse.core.snapshots import SnapshotRecord, write_snapshot from muse.core.object_store import write_object from muse.core.types import blob_id from muse.core.paths import muse_dir, ref_path runner = CliRunner() # --------------------------------------------------------------------------- # Helpers (mirrors tests/test_cmd_format_patch.py's fixture pattern) # --------------------------------------------------------------------------- def _init_repo(path: pathlib.Path) -> pathlib.Path: dot_muse = muse_dir(path) for sub in ("commits", "snapshots", "objects", "refs/heads"): (dot_muse / sub).mkdir(parents=True, exist_ok=True) (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") (dot_muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo", "domain": "code"})) return path def _write_object(repo: pathlib.Path, content: bytes) -> str: oid = blob_id(content) write_object(repo, oid, content) return oid def _commit( repo: pathlib.Path, msg: str, manifest: dict[str, str], branch: str = "main", parent: str | None = None, ts: datetime.datetime | None = None, ) -> str: ts = ts or datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) sid = hash_snapshot(manifest) write_snapshot(repo, SnapshotRecord(snapshot_id=sid, manifest=manifest, created_at=ts)) parent_ids = [parent] if parent else [] cid = hash_commit( parent_ids=parent_ids, snapshot_id=sid, message=msg, committed_at_iso=ts.isoformat(), author="gabriel", ) write_commit(repo, CommitRecord( commit_id=cid, branch=branch, snapshot_id=sid, message=msg, committed_at=ts, author="gabriel", parent_commit_id=parent, parent2_commit_id=None, )) branch_ref = ref_path(repo, branch) branch_ref.parent.mkdir(parents=True, exist_ok=True) branch_ref.write_text(cid) return cid def _fp(repo: pathlib.Path, *args: str) -> InvokeResult: return runner.invoke(None, ["format-patch", *args], env={"MUSE_REPO_ROOT": str(repo)}) def _rev_list(repo: pathlib.Path, *args: str) -> InvokeResult: return runner.invoke(None, ["rev-list", *args], env={"MUSE_REPO_ROOT": str(repo)}) @pytest.fixture() def three_commit_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, list[str]]: """Repo with 3 commits on main: c1 (root) -> c2 -> c3 (HEAD).""" repo = _init_repo(tmp_path) oid_a = _write_object(repo, b"content-a-v1\n") c1 = _commit(repo, "c1", {"a.py": oid_a}) oid_a2 = _write_object(repo, b"content-a-v2\n") c2 = _commit(repo, "c2", {"a.py": oid_a2}, parent=c1) oid_b = _write_object(repo, b"content-b\n") c3 = _commit(repo, "c3", {"a.py": oid_a2, "b.py": oid_b}, parent=c2) return repo, [c1, c2, c3] # --------------------------------------------------------------------------- # format-patch: -N shorthand # --------------------------------------------------------------------------- class TestFormatPatchDashN: def test_dash_1_exports_last_commit_only( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: repo, (c1, c2, c3) = three_commit_repo result = _fp(repo, "-1", "--json") assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["count"] == 1 assert data["patches"][0]["to_commit_id"] == c3 def test_dash_2_exports_last_two_oldest_first( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: repo, (c1, c2, c3) = three_commit_repo result = _fp(repo, "-2", "--json") assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["count"] == 2 # Oldest first — git's own format-patch numbering convention. assert data["patches"][0]["to_commit_id"] == c2 assert data["patches"][1]["to_commit_id"] == c3 def test_dash_n_exceeding_history_returns_all_available( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: repo, (c1, c2, c3) = three_commit_repo result = _fp(repo, "-10", "--json") assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["count"] == 3 def test_dash_zero_is_rejected( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: repo, _ = three_commit_repo result = _fp(repo, "-0", "--json") assert result.exit_code != 0 def test_dash_n_output_dir_writes_numbered_files( self, three_commit_repo: tuple[pathlib.Path, list[str]], tmp_path: pathlib.Path ) -> None: repo, (c1, c2, c3) = three_commit_repo out_dir = tmp_path / "patches" out_dir.mkdir() result = _fp(repo, "-2", "--output-dir", str(out_dir)) assert result.exit_code == 0, result.output files = sorted(p.name for p in out_dir.iterdir()) assert len(files) == 2 assert files[0].startswith("0001-") assert files[1].startswith("0002-") # --------------------------------------------------------------------------- # format-patch: A..B range semantics # --------------------------------------------------------------------------- class TestFormatPatchRange: def test_range_exports_commits_since_base_exclusive( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: """This is the exact ticket scenario: passing the base (parent) ref alone exports only that commit, but 'base..HEAD' exports everything since it — c2 and c3, not c1.""" repo, (c1, c2, c3) = three_commit_repo result = _fp(repo, f"{c1}..HEAD", "--json") assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["count"] == 2 to_ids = [p["to_commit_id"] for p in data["patches"]] assert to_ids == [c2, c3] # oldest first, c1 excluded def test_bare_ref_still_exports_only_that_single_commit( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: """Regression guard for the documented (intentional) divergence from git: muse format-patch without '..' or '-N' exports ONLY that commit, never a range.""" repo, (c1, c2, c3) = three_commit_repo result = _fp(repo, c1, "--json") assert result.exit_code == 0, result.output data = json.loads(result.output) assert "patch_id" in data # single-commit schema, not count/patches assert "count" not in data assert data["to_commit_id"] == c1 def test_range_with_empty_local_side_rejected( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: repo, _ = three_commit_repo result = _fp(repo, "..HEAD", "--json") assert result.exit_code != 0 def test_default_head_unchanged_single_commit_schema( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: """No args at all: default behavior (HEAD, single commit) is 100% unchanged from before this ticket.""" repo, (c1, c2, c3) = three_commit_repo result = _fp(repo, "--json") assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["to_commit_id"] == c3 assert "count" not in data # --------------------------------------------------------------------------- # rev-list: -N shorthand + -n short flag (muse#37 audit sweep) # --------------------------------------------------------------------------- class TestRevListDashN: def test_dash_1_alone_defaults_to_head( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: repo, (c1, c2, c3) = three_commit_repo result = _rev_list(repo, "-1", "--json") assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["commit_ids"] == [c3] def test_dash_n_with_explicit_ref( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: repo, (c1, c2, c3) = three_commit_repo result = _rev_list(repo, "-2", "HEAD", "--json") assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["commit_ids"] == [c3, c2] # newest-first (rev-list default) def test_dash_n_either_order( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: repo, (c1, c2, c3) = three_commit_repo result = _rev_list(repo, "HEAD", "-2", "--json") assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["commit_ids"] == [c3, c2] def test_short_flag_n_alias_for_max_count( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: repo, (c1, c2, c3) = three_commit_repo result = _rev_list(repo, "-n", "1", "HEAD", "--json") assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["commit_ids"] == [c3] def test_too_many_refs_rejected( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: repo, (c1, c2, c3) = three_commit_repo result = _rev_list(repo, "HEAD", "main", "--json") assert result.exit_code != 0 def test_two_dash_n_tokens_rejected( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: repo, (c1, c2, c3) = three_commit_repo result = _rev_list(repo, "-1", "-2", "--json") assert result.exit_code != 0 def test_range_syntax_unaffected( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: """Regression guard: A..B range parsing still works after the ref positional became nargs='*'.""" repo, (c1, c2, c3) = three_commit_repo result = _rev_list(repo, f"{c1}..HEAD", "--json") assert result.exit_code == 0, result.output data = json.loads(result.output) assert sorted(data["commit_ids"]) == sorted([c2, c3]) def test_plain_ref_unaffected( self, three_commit_repo: tuple[pathlib.Path, list[str]] ) -> None: repo, (c1, c2, c3) = three_commit_repo result = _rev_list(repo, "HEAD", "--json") assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["commit_ids"] == [c3, c2, c1]