test_muse_symbolic_ref.py
python
| 1 | """Tests for ``muse symbolic-ref`` — read or write a symbolic ref. |
| 2 | |
| 3 | Verifies: |
| 4 | - read_symbolic_ref: returns SymbolicRefResult for a valid symbolic ref. |
| 5 | - read_symbolic_ref: returns None for a non-existent file. |
| 6 | - read_symbolic_ref: returns None when content is a bare SHA (not symbolic). |
| 7 | - read_symbolic_ref.short: last path component only. |
| 8 | - write_symbolic_ref: creates the file with the given target. |
| 9 | - write_symbolic_ref: raises ValueError for a non-refs/ target. |
| 10 | - write_symbolic_ref: creates intermediate directories for nested refs. |
| 11 | - delete_symbolic_ref: removes the file; returns True. |
| 12 | - delete_symbolic_ref: returns False for a non-existent file. |
| 13 | - CLI read path: ``muse symbolic-ref HEAD`` prints full ref. |
| 14 | - CLI --short: prints just the branch name. |
| 15 | - CLI write path: ``muse symbolic-ref HEAD refs/heads/x`` updates the file. |
| 16 | - CLI write rejects non-refs/ target. |
| 17 | - CLI --delete: removes the file; exits 0. |
| 18 | - CLI --delete absent file: exits USER_ERROR. |
| 19 | - CLI -q suppresses error output when ref is not symbolic. |
| 20 | - Boundary seal (AST): ``from __future__ import annotations`` present. |
| 21 | """ |
| 22 | from __future__ import annotations |
| 23 | |
| 24 | import ast |
| 25 | import pathlib |
| 26 | |
| 27 | import pytest |
| 28 | from typer.testing import CliRunner |
| 29 | |
| 30 | from maestro.muse_cli.app import cli |
| 31 | from maestro.muse_cli.commands.symbolic_ref import ( |
| 32 | SymbolicRefResult, |
| 33 | delete_symbolic_ref, |
| 34 | read_symbolic_ref, |
| 35 | write_symbolic_ref, |
| 36 | ) |
| 37 | from maestro.muse_cli.errors import ExitCode |
| 38 | |
| 39 | runner = CliRunner() |
| 40 | |
| 41 | |
| 42 | # --------------------------------------------------------------------------- |
| 43 | # Fixtures |
| 44 | # --------------------------------------------------------------------------- |
| 45 | |
| 46 | |
| 47 | @pytest.fixture |
| 48 | def muse_dir(tmp_path: pathlib.Path) -> pathlib.Path: |
| 49 | """Create a minimal .muse directory.""" |
| 50 | d = tmp_path / ".muse" |
| 51 | d.mkdir() |
| 52 | return d |
| 53 | |
| 54 | |
| 55 | @pytest.fixture |
| 56 | def repo_root_with_head(tmp_path: pathlib.Path) -> pathlib.Path: |
| 57 | """Full repo directory with .muse/HEAD pointing at refs/heads/main.""" |
| 58 | muse = tmp_path / ".muse" |
| 59 | muse.mkdir() |
| 60 | (muse / "HEAD").write_text("refs/heads/main\n") |
| 61 | refs = muse / "refs" / "heads" |
| 62 | refs.mkdir(parents=True) |
| 63 | return tmp_path |
| 64 | |
| 65 | |
| 66 | # --------------------------------------------------------------------------- |
| 67 | # Unit tests — pure logic |
| 68 | # --------------------------------------------------------------------------- |
| 69 | |
| 70 | |
| 71 | def test_read_symbolic_ref_returns_result(muse_dir: pathlib.Path) -> None: |
| 72 | (muse_dir / "HEAD").write_text("refs/heads/main\n") |
| 73 | result = read_symbolic_ref(muse_dir, "HEAD") |
| 74 | assert result is not None |
| 75 | assert result.ref == "refs/heads/main" |
| 76 | assert result.name == "HEAD" |
| 77 | assert result.short == "main" |
| 78 | |
| 79 | |
| 80 | def test_read_symbolic_ref_missing_file_returns_none(muse_dir: pathlib.Path) -> None: |
| 81 | result = read_symbolic_ref(muse_dir, "HEAD", quiet=True) |
| 82 | assert result is None |
| 83 | |
| 84 | |
| 85 | def test_read_symbolic_ref_bare_sha_returns_none(muse_dir: pathlib.Path) -> None: |
| 86 | """Detached HEAD contains a bare SHA — not a symbolic ref.""" |
| 87 | (muse_dir / "HEAD").write_text("a" * 64 + "\n") |
| 88 | result = read_symbolic_ref(muse_dir, "HEAD", quiet=True) |
| 89 | assert result is None |
| 90 | |
| 91 | |
| 92 | def test_symbolic_ref_result_short_no_slash() -> None: |
| 93 | """A ref without slashes uses the whole string as short form.""" |
| 94 | result = SymbolicRefResult(name="HEAD", ref="main") |
| 95 | assert result.short == "main" |
| 96 | |
| 97 | |
| 98 | def test_write_symbolic_ref_creates_file(muse_dir: pathlib.Path) -> None: |
| 99 | write_symbolic_ref(muse_dir, "HEAD", "refs/heads/feature/x") |
| 100 | content = (muse_dir / "HEAD").read_text().strip() |
| 101 | assert content == "refs/heads/feature/x" |
| 102 | |
| 103 | |
| 104 | def test_write_symbolic_ref_raises_for_non_refs_target(muse_dir: pathlib.Path) -> None: |
| 105 | with pytest.raises(ValueError, match="must start with 'refs/'"): |
| 106 | write_symbolic_ref(muse_dir, "HEAD", "main") |
| 107 | |
| 108 | |
| 109 | def test_write_symbolic_ref_creates_intermediate_dirs(muse_dir: pathlib.Path) -> None: |
| 110 | write_symbolic_ref(muse_dir, "refs/heads/feature/guitar", "refs/heads/main") |
| 111 | assert (muse_dir / "refs" / "heads" / "feature" / "guitar").exists() |
| 112 | |
| 113 | |
| 114 | def test_delete_symbolic_ref_removes_file(muse_dir: pathlib.Path) -> None: |
| 115 | (muse_dir / "HEAD").write_text("refs/heads/main\n") |
| 116 | deleted = delete_symbolic_ref(muse_dir, "HEAD") |
| 117 | assert deleted is True |
| 118 | assert not (muse_dir / "HEAD").exists() |
| 119 | |
| 120 | |
| 121 | def test_delete_symbolic_ref_absent_returns_false(muse_dir: pathlib.Path) -> None: |
| 122 | deleted = delete_symbolic_ref(muse_dir, "HEAD", quiet=True) |
| 123 | assert deleted is False |
| 124 | |
| 125 | |
| 126 | # --------------------------------------------------------------------------- |
| 127 | # CLI integration tests |
| 128 | # --------------------------------------------------------------------------- |
| 129 | |
| 130 | |
| 131 | def test_cli_read_prints_full_ref(repo_root_with_head: pathlib.Path) -> None: |
| 132 | result = runner.invoke(cli, ["symbolic-ref", "HEAD"], env={"MUSE_REPO_ROOT": str(repo_root_with_head)}) |
| 133 | assert result.exit_code == ExitCode.SUCCESS |
| 134 | assert "refs/heads/main" in result.output |
| 135 | |
| 136 | |
| 137 | def test_cli_read_short_prints_branch_name(repo_root_with_head: pathlib.Path) -> None: |
| 138 | result = runner.invoke( |
| 139 | cli, |
| 140 | ["symbolic-ref", "--short", "HEAD"], |
| 141 | env={"MUSE_REPO_ROOT": str(repo_root_with_head)}, |
| 142 | ) |
| 143 | assert result.exit_code == ExitCode.SUCCESS |
| 144 | assert result.output.strip() == "main" |
| 145 | assert "refs/heads/main" not in result.output |
| 146 | |
| 147 | |
| 148 | def test_cli_write_updates_file(repo_root_with_head: pathlib.Path) -> None: |
| 149 | result = runner.invoke( |
| 150 | cli, |
| 151 | ["symbolic-ref", "HEAD", "refs/heads/feature/guitar"], |
| 152 | env={"MUSE_REPO_ROOT": str(repo_root_with_head)}, |
| 153 | ) |
| 154 | assert result.exit_code == ExitCode.SUCCESS |
| 155 | content = (repo_root_with_head / ".muse" / "HEAD").read_text().strip() |
| 156 | assert content == "refs/heads/feature/guitar" |
| 157 | |
| 158 | |
| 159 | def test_cli_write_rejects_non_refs_target(repo_root_with_head: pathlib.Path) -> None: |
| 160 | result = runner.invoke( |
| 161 | cli, |
| 162 | ["symbolic-ref", "HEAD", "main"], |
| 163 | env={"MUSE_REPO_ROOT": str(repo_root_with_head)}, |
| 164 | ) |
| 165 | assert result.exit_code == ExitCode.USER_ERROR |
| 166 | assert "refs/" in result.output |
| 167 | |
| 168 | |
| 169 | def test_cli_delete_removes_file(repo_root_with_head: pathlib.Path) -> None: |
| 170 | result = runner.invoke( |
| 171 | cli, |
| 172 | ["symbolic-ref", "--delete", "HEAD"], |
| 173 | env={"MUSE_REPO_ROOT": str(repo_root_with_head)}, |
| 174 | ) |
| 175 | assert result.exit_code == ExitCode.SUCCESS |
| 176 | assert not (repo_root_with_head / ".muse" / "HEAD").exists() |
| 177 | |
| 178 | |
| 179 | def test_cli_delete_absent_file_exits_user_error(repo_root_with_head: pathlib.Path) -> None: |
| 180 | muse_dir = repo_root_with_head / ".muse" |
| 181 | (muse_dir / "HEAD").unlink() |
| 182 | result = runner.invoke( |
| 183 | cli, |
| 184 | ["symbolic-ref", "--delete", "HEAD"], |
| 185 | env={"MUSE_REPO_ROOT": str(repo_root_with_head)}, |
| 186 | ) |
| 187 | assert result.exit_code == ExitCode.USER_ERROR |
| 188 | |
| 189 | |
| 190 | def test_cli_read_non_symbolic_exits_user_error(repo_root_with_head: pathlib.Path) -> None: |
| 191 | """Detached HEAD (bare SHA) should exit USER_ERROR on read.""" |
| 192 | (repo_root_with_head / ".muse" / "HEAD").write_text("a" * 64 + "\n") |
| 193 | result = runner.invoke( |
| 194 | cli, |
| 195 | ["symbolic-ref", "HEAD"], |
| 196 | env={"MUSE_REPO_ROOT": str(repo_root_with_head)}, |
| 197 | ) |
| 198 | assert result.exit_code == ExitCode.USER_ERROR |
| 199 | |
| 200 | |
| 201 | def test_cli_quiet_suppresses_error_output(repo_root_with_head: pathlib.Path) -> None: |
| 202 | """With -q, reading a non-symbolic ref should produce no user-facing text.""" |
| 203 | (repo_root_with_head / ".muse" / "HEAD").write_text("a" * 64 + "\n") |
| 204 | result = runner.invoke( |
| 205 | cli, |
| 206 | ["symbolic-ref", "-q", "HEAD"], |
| 207 | env={"MUSE_REPO_ROOT": str(repo_root_with_head)}, |
| 208 | ) |
| 209 | assert result.exit_code == ExitCode.USER_ERROR |
| 210 | assert result.output.strip() == "" |
| 211 | |
| 212 | |
| 213 | # --------------------------------------------------------------------------- |
| 214 | # Boundary seal |
| 215 | # --------------------------------------------------------------------------- |
| 216 | |
| 217 | |
| 218 | def test_boundary_seal_future_annotations() -> None: |
| 219 | """Verify ``from __future__ import annotations`` is the first import.""" |
| 220 | import maestro.muse_cli.commands.symbolic_ref as mod |
| 221 | |
| 222 | source = pathlib.Path(mod.__file__).read_text() |
| 223 | tree = ast.parse(source) |
| 224 | for node in ast.walk(tree): |
| 225 | if isinstance(node, ast.ImportFrom): |
| 226 | if node.module == "__future__" and any( |
| 227 | alias.name == "annotations" for alias in node.names |
| 228 | ): |
| 229 | return |
| 230 | pytest.fail("from __future__ import annotations not found in symbolic_ref.py") |