test_reflog_ref_resolution.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """Phase 5 — RL_21–RL_27: @{N} ref resolution. |
| 2 | |
| 3 | TDD: tests written before implementation. |
| 4 | |
| 5 | Coverage tiers |
| 6 | -------------- |
| 7 | RL_21 muse rev-parse @{0} --json resolves to the commit_id at HEAD reflog index 0 |
| 8 | RL_22 muse reset @{1} --json resets HEAD to reflog index 1; muse read confirms it |
| 9 | RL_23 muse read @{2} --json reads the commit at HEAD reflog index 2 |
| 10 | RL_24 dev@{0} resolves to the dev branch reflog's newest entry commit ID |
| 11 | RL_25 Out-of-range index exits USER_ERROR with valid-range message |
| 12 | RL_26 muse diff @{1} HEAD --json shows the diff between reflog state 1 and HEAD |
| 13 | RL_27 End-to-end recovery: commit A → commit B → muse reset @{1} → back at A |
| 14 | """ |
| 15 | from __future__ import annotations |
| 16 | |
| 17 | import datetime |
| 18 | import json |
| 19 | import pathlib |
| 20 | import time |
| 21 | |
| 22 | import pytest |
| 23 | |
| 24 | from muse.core.paths import muse_dir, logs_dir |
| 25 | from muse.core.types import NULL_COMMIT_ID |
| 26 | from tests.cli_test_helper import CliRunner |
| 27 | |
| 28 | runner = CliRunner() |
| 29 | cli = None |
| 30 | |
| 31 | # --------------------------------------------------------------------------- |
| 32 | # Helpers |
| 33 | # --------------------------------------------------------------------------- |
| 34 | |
| 35 | def _env(root: pathlib.Path) -> dict[str, str]: |
| 36 | return {"MUSE_REPO_ROOT": str(root)} |
| 37 | |
| 38 | |
| 39 | def _head_log(root: pathlib.Path) -> pathlib.Path: |
| 40 | return logs_dir(root) / "HEAD" |
| 41 | |
| 42 | |
| 43 | def _branch_log(root: pathlib.Path, branch: str) -> pathlib.Path: |
| 44 | return logs_dir(root) / "refs" / "heads" / branch |
| 45 | |
| 46 | |
| 47 | def _write_reflog_entry( |
| 48 | log_path: pathlib.Path, |
| 49 | old_id: str, |
| 50 | new_id: str, |
| 51 | operation: str, |
| 52 | ) -> None: |
| 53 | """Append one raw reflog line (oldest-first order, newest appended last).""" |
| 54 | ts = int(time.time()) |
| 55 | line = f"{old_id} {new_id} user {ts} +0000\t{operation}\n" |
| 56 | log_path.parent.mkdir(parents=True, exist_ok=True) |
| 57 | with log_path.open("a", encoding="utf-8") as fh: |
| 58 | fh.write(line) |
| 59 | |
| 60 | |
| 61 | def _make_commit( |
| 62 | root: pathlib.Path, |
| 63 | parent_id: str | None, |
| 64 | branch: str, |
| 65 | message: str, |
| 66 | filename: str, |
| 67 | content: bytes, |
| 68 | ) -> str: |
| 69 | """Write a commit to the object store and return its commit_id. |
| 70 | |
| 71 | Does NOT advance the branch ref — callers do that. |
| 72 | """ |
| 73 | from muse.core.commits import CommitRecord, write_commit |
| 74 | from muse.core.ids import hash_commit, hash_snapshot |
| 75 | from muse.core.object_store import write_object |
| 76 | from muse.core.snapshots import SnapshotRecord, write_snapshot |
| 77 | from muse.core.types import blob_id |
| 78 | |
| 79 | oid = blob_id(content) |
| 80 | write_object(root, oid, content) |
| 81 | snap_manifest = {filename: oid} |
| 82 | snap_id = hash_snapshot(snap_manifest) |
| 83 | write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=snap_manifest)) |
| 84 | ts = datetime.datetime.now(tz=datetime.timezone.utc) |
| 85 | parent_ids = [parent_id] if parent_id else [] |
| 86 | cid = hash_commit( |
| 87 | parent_ids=parent_ids, |
| 88 | snapshot_id=snap_id, |
| 89 | message=message, |
| 90 | committed_at_iso=ts.isoformat(), |
| 91 | ) |
| 92 | write_commit(root, CommitRecord( |
| 93 | commit_id=cid, branch=branch, snapshot_id=snap_id, |
| 94 | message=message, committed_at=ts, |
| 95 | parent_commit_id=parent_id, |
| 96 | )) |
| 97 | return cid |
| 98 | |
| 99 | |
| 100 | def _set_branch(root: pathlib.Path, branch: str, commit_id: str) -> None: |
| 101 | """Unconditionally advance a branch ref to commit_id.""" |
| 102 | (muse_dir(root) / "refs" / "heads" / branch).write_text(commit_id) |
| 103 | |
| 104 | |
| 105 | def _make_base_repo(tmp_path: pathlib.Path, branch: str = "main") -> pathlib.Path: |
| 106 | """Create the minimal directory structure for a Muse repo (no commits).""" |
| 107 | from muse._version import __version__ |
| 108 | |
| 109 | dot = muse_dir(tmp_path) |
| 110 | for sub in ("refs/heads", "objects/sha256", "commits", "snapshots", |
| 111 | "logs/refs/heads", "logs"): |
| 112 | (dot / sub).mkdir(parents=True, exist_ok=True) |
| 113 | (dot / "repo.json").write_text( |
| 114 | json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"}) |
| 115 | ) |
| 116 | (dot / "HEAD").write_text(f"ref: refs/heads/{branch}\n") |
| 117 | (dot / "config.toml").write_text("") |
| 118 | return tmp_path |
| 119 | |
| 120 | |
| 121 | def _setup_repo_with_history( |
| 122 | tmp_path: pathlib.Path, |
| 123 | n_commits: int = 3, |
| 124 | branch: str = "main", |
| 125 | ) -> tuple[pathlib.Path, list[str]]: |
| 126 | """Create a repo with n_commits and matching reflog entries. |
| 127 | |
| 128 | Returns (root, commit_ids) where commit_ids[0] is the oldest and |
| 129 | commit_ids[-1] is the current HEAD (newest). |
| 130 | |
| 131 | Reflog is written oldest-first so: |
| 132 | index 0 (newest) = commit_ids[-1] |
| 133 | index 1 = commit_ids[-2] |
| 134 | index 2 = commit_ids[-3] |
| 135 | """ |
| 136 | root = _make_base_repo(tmp_path, branch=branch) |
| 137 | commit_ids: list[str] = [] |
| 138 | prev: str | None = None |
| 139 | |
| 140 | for i in range(n_commits): |
| 141 | cid = _make_commit( |
| 142 | root, prev, branch, |
| 143 | message=f"commit-{i}", |
| 144 | filename=f"file{i}.txt", |
| 145 | content=f"content-{i}\n".encode(), |
| 146 | ) |
| 147 | commit_ids.append(cid) |
| 148 | old = prev if prev is not None else NULL_COMMIT_ID |
| 149 | _write_reflog_entry(_head_log(root), old, cid, f"commit: commit-{i}") |
| 150 | _write_reflog_entry(_branch_log(root, branch), old, cid, f"commit: commit-{i}") |
| 151 | prev = cid |
| 152 | |
| 153 | # Set branch to the newest commit. |
| 154 | _set_branch(root, branch, commit_ids[-1]) |
| 155 | return root, commit_ids |
| 156 | |
| 157 | |
| 158 | # --------------------------------------------------------------------------- |
| 159 | # RL_21 — muse rev-parse @{0} resolves to commit_id at HEAD reflog index 0 |
| 160 | # --------------------------------------------------------------------------- |
| 161 | |
| 162 | class TestRevParseAtN: |
| 163 | """RL_21: rev-parse @{N} resolves via the HEAD reflog.""" |
| 164 | |
| 165 | def test_at_zero_resolves_to_newest_commit(self, tmp_path: pathlib.Path) -> None: |
| 166 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3) |
| 167 | newest = cids[-1] |
| 168 | |
| 169 | r = runner.invoke(cli, ["rev-parse", "@{0}", "--json"], env=_env(root)) |
| 170 | |
| 171 | assert r.exit_code == 0, r.output |
| 172 | data = json.loads(r.output) |
| 173 | assert data["commit_id"] == newest |
| 174 | |
| 175 | def test_at_one_resolves_to_second_newest(self, tmp_path: pathlib.Path) -> None: |
| 176 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3) |
| 177 | |
| 178 | r = runner.invoke(cli, ["rev-parse", "@{1}", "--json"], env=_env(root)) |
| 179 | |
| 180 | assert r.exit_code == 0, r.output |
| 181 | data = json.loads(r.output) |
| 182 | assert data["commit_id"] == cids[-2] |
| 183 | |
| 184 | def test_at_zero_text_mode_prints_commit_id(self, tmp_path: pathlib.Path) -> None: |
| 185 | root, cids = _setup_repo_with_history(tmp_path, n_commits=2) |
| 186 | |
| 187 | r = runner.invoke(cli, ["rev-parse", "@{0}"], env=_env(root)) |
| 188 | |
| 189 | assert r.exit_code == 0, r.output |
| 190 | assert r.output.strip() == cids[-1] |
| 191 | |
| 192 | def test_ref_field_echoes_at_spec(self, tmp_path: pathlib.Path) -> None: |
| 193 | root, cids = _setup_repo_with_history(tmp_path, n_commits=2) |
| 194 | |
| 195 | r = runner.invoke(cli, ["rev-parse", "@{0}", "--json"], env=_env(root)) |
| 196 | |
| 197 | data = json.loads(r.output) |
| 198 | assert data["ref"] == "@{0}" |
| 199 | |
| 200 | |
| 201 | # --------------------------------------------------------------------------- |
| 202 | # RL_22 — muse reset @{1} resets HEAD to reflog index 1 |
| 203 | # --------------------------------------------------------------------------- |
| 204 | |
| 205 | class TestResetAtN: |
| 206 | """RL_22: reset @{N} moves the branch pointer to the commit at index N.""" |
| 207 | |
| 208 | def test_reset_at_one_moves_to_second_commit(self, tmp_path: pathlib.Path) -> None: |
| 209 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3) |
| 210 | |
| 211 | r = runner.invoke(cli, ["reset", "@{1}", "--json"], env=_env(root)) |
| 212 | |
| 213 | assert r.exit_code == 0, r.output |
| 214 | data = json.loads(r.output) |
| 215 | assert data["new_commit_id"] == cids[-2] |
| 216 | |
| 217 | def test_reset_at_two_moves_to_oldest_commit(self, tmp_path: pathlib.Path) -> None: |
| 218 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3) |
| 219 | |
| 220 | runner.invoke(cli, ["reset", "@{2}"], env=_env(root)) |
| 221 | |
| 222 | r = runner.invoke(cli, ["rev-parse", "HEAD", "--json"], env=_env(root)) |
| 223 | data = json.loads(r.output) |
| 224 | assert data["commit_id"] == cids[0] |
| 225 | |
| 226 | def test_branch_ref_advances_after_reset(self, tmp_path: pathlib.Path) -> None: |
| 227 | """After muse reset @{1}, the branch ref file must contain the target.""" |
| 228 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3) |
| 229 | target = cids[-2] |
| 230 | |
| 231 | runner.invoke(cli, ["reset", "@{1}"], env=_env(root)) |
| 232 | |
| 233 | from muse.core.refs import read_ref |
| 234 | from muse.core.paths import ref_path |
| 235 | actual = read_ref(ref_path(root, "main")) |
| 236 | assert actual == target |
| 237 | |
| 238 | |
| 239 | # --------------------------------------------------------------------------- |
| 240 | # RL_23 — muse read @{2} reads the commit at HEAD reflog index 2 |
| 241 | # --------------------------------------------------------------------------- |
| 242 | |
| 243 | class TestReadAtN: |
| 244 | """RL_23: muse read @{N} reads the commit at reflog index N.""" |
| 245 | |
| 246 | def test_read_at_zero_shows_newest_commit(self, tmp_path: pathlib.Path) -> None: |
| 247 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3) |
| 248 | |
| 249 | r = runner.invoke(cli, ["read", "@{0}", "--json"], env=_env(root)) |
| 250 | |
| 251 | assert r.exit_code == 0, r.output |
| 252 | data = json.loads(r.output) |
| 253 | assert data["commit_id"] == cids[-1] |
| 254 | |
| 255 | def test_read_at_two_shows_oldest_commit(self, tmp_path: pathlib.Path) -> None: |
| 256 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3) |
| 257 | |
| 258 | r = runner.invoke(cli, ["read", "@{2}", "--json"], env=_env(root)) |
| 259 | |
| 260 | assert r.exit_code == 0, r.output |
| 261 | data = json.loads(r.output) |
| 262 | assert data["commit_id"] == cids[0] |
| 263 | |
| 264 | def test_read_at_n_shows_correct_message(self, tmp_path: pathlib.Path) -> None: |
| 265 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3) |
| 266 | |
| 267 | r = runner.invoke(cli, ["read", "@{1}", "--json"], env=_env(root)) |
| 268 | |
| 269 | data = json.loads(r.output) |
| 270 | assert data["message"] == "commit-1" |
| 271 | |
| 272 | |
| 273 | # --------------------------------------------------------------------------- |
| 274 | # RL_24 — dev@{0} resolves to the dev branch reflog's newest commit ID |
| 275 | # --------------------------------------------------------------------------- |
| 276 | |
| 277 | class TestBranchAtN: |
| 278 | """RL_24: <branch>@{N} resolves via the named branch reflog.""" |
| 279 | |
| 280 | def test_branch_at_zero_resolves_to_newest(self, tmp_path: pathlib.Path) -> None: |
| 281 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3, branch="dev") |
| 282 | |
| 283 | r = runner.invoke(cli, ["rev-parse", "dev@{0}", "--json"], env=_env(root)) |
| 284 | |
| 285 | assert r.exit_code == 0, r.output |
| 286 | data = json.loads(r.output) |
| 287 | assert data["commit_id"] == cids[-1] |
| 288 | |
| 289 | def test_branch_at_one_resolves_via_branch_log(self, tmp_path: pathlib.Path) -> None: |
| 290 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3, branch="dev") |
| 291 | |
| 292 | r = runner.invoke(cli, ["rev-parse", "dev@{1}", "--json"], env=_env(root)) |
| 293 | |
| 294 | assert r.exit_code == 0, r.output |
| 295 | data = json.loads(r.output) |
| 296 | assert data["commit_id"] == cids[-2] |
| 297 | |
| 298 | def test_refs_heads_prefix_form_accepted(self, tmp_path: pathlib.Path) -> None: |
| 299 | """refs/heads/dev@{0} is accepted alongside the short form dev@{0}.""" |
| 300 | root, cids = _setup_repo_with_history(tmp_path, n_commits=2, branch="dev") |
| 301 | |
| 302 | r = runner.invoke( |
| 303 | cli, ["rev-parse", "refs/heads/dev@{0}", "--json"], env=_env(root) |
| 304 | ) |
| 305 | |
| 306 | assert r.exit_code == 0, r.output |
| 307 | data = json.loads(r.output) |
| 308 | assert data["commit_id"] == cids[-1] |
| 309 | |
| 310 | def test_head_log_and_branch_log_resolve_independently( |
| 311 | self, tmp_path: pathlib.Path |
| 312 | ) -> None: |
| 313 | """@{0} and main@{0} can differ when HEAD has moved since last branch op.""" |
| 314 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3, branch="main") |
| 315 | |
| 316 | # @{0} uses HEAD log → newest commit on main |
| 317 | r_head = runner.invoke(cli, ["rev-parse", "@{0}", "--json"], env=_env(root)) |
| 318 | # main@{0} uses main branch log → also newest (in this scenario they match) |
| 319 | r_branch = runner.invoke( |
| 320 | cli, ["rev-parse", "main@{0}", "--json"], env=_env(root) |
| 321 | ) |
| 322 | |
| 323 | assert r_head.exit_code == 0 and r_branch.exit_code == 0 |
| 324 | assert json.loads(r_head.output)["commit_id"] == cids[-1] |
| 325 | assert json.loads(r_branch.output)["commit_id"] == cids[-1] |
| 326 | |
| 327 | |
| 328 | # --------------------------------------------------------------------------- |
| 329 | # RL_25 — Out-of-range index exits USER_ERROR with valid-range message |
| 330 | # --------------------------------------------------------------------------- |
| 331 | |
| 332 | class TestAtNOutOfRange: |
| 333 | """RL_25: @{N} where N >= total entries → USER_ERROR with range in message.""" |
| 334 | |
| 335 | def test_out_of_range_rev_parse_exits_user_error( |
| 336 | self, tmp_path: pathlib.Path |
| 337 | ) -> None: |
| 338 | root, _ = _setup_repo_with_history(tmp_path, n_commits=2) |
| 339 | |
| 340 | r = runner.invoke(cli, ["rev-parse", "@{99}", "--json"], env=_env(root)) |
| 341 | |
| 342 | assert r.exit_code == 1, r.output |
| 343 | |
| 344 | def test_out_of_range_error_mentions_valid_range( |
| 345 | self, tmp_path: pathlib.Path |
| 346 | ) -> None: |
| 347 | """Error message must include the valid range so the user knows what's available.""" |
| 348 | root, _ = _setup_repo_with_history(tmp_path, n_commits=2) |
| 349 | # 2 entries → valid indices are 0 and 1 |
| 350 | |
| 351 | r = runner.invoke(cli, ["rev-parse", "@{99}", "--json"], env=_env(root)) |
| 352 | |
| 353 | combined = r.output + (r.stderr or "") |
| 354 | # "0" and "1" (the valid range bounds) must appear |
| 355 | assert "0" in combined and "1" in combined |
| 356 | |
| 357 | def test_out_of_range_reset_exits_user_error( |
| 358 | self, tmp_path: pathlib.Path |
| 359 | ) -> None: |
| 360 | root, _ = _setup_repo_with_history(tmp_path, n_commits=2) |
| 361 | |
| 362 | r = runner.invoke(cli, ["reset", "@{99}", "--json"], env=_env(root)) |
| 363 | |
| 364 | assert r.exit_code != 0 |
| 365 | |
| 366 | def test_no_log_file_gives_user_error(self, tmp_path: pathlib.Path) -> None: |
| 367 | root = _make_base_repo(tmp_path) |
| 368 | # No reflog written |
| 369 | |
| 370 | r = runner.invoke(cli, ["rev-parse", "@{0}", "--json"], env=_env(root)) |
| 371 | |
| 372 | assert r.exit_code == 1 |
| 373 | |
| 374 | def test_branch_at_n_out_of_range(self, tmp_path: pathlib.Path) -> None: |
| 375 | root, _ = _setup_repo_with_history(tmp_path, n_commits=1, branch="main") |
| 376 | |
| 377 | r = runner.invoke(cli, ["rev-parse", "main@{5}", "--json"], env=_env(root)) |
| 378 | |
| 379 | assert r.exit_code == 1 |
| 380 | |
| 381 | |
| 382 | # --------------------------------------------------------------------------- |
| 383 | # RL_26 — muse diff @{1} HEAD shows the diff between reflog state 1 and HEAD |
| 384 | # --------------------------------------------------------------------------- |
| 385 | |
| 386 | class TestDiffAtN: |
| 387 | """RL_26: muse diff @{1} HEAD resolves both sides via reflog.""" |
| 388 | |
| 389 | def test_diff_at_one_head_exits_zero(self, tmp_path: pathlib.Path) -> None: |
| 390 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3) |
| 391 | |
| 392 | r = runner.invoke( |
| 393 | cli, ["diff", "@{1}", "HEAD", "--json"], env=_env(root) |
| 394 | ) |
| 395 | |
| 396 | assert r.exit_code == 0, r.output |
| 397 | |
| 398 | def test_diff_at_one_head_has_required_json_fields( |
| 399 | self, tmp_path: pathlib.Path |
| 400 | ) -> None: |
| 401 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3) |
| 402 | |
| 403 | r = runner.invoke( |
| 404 | cli, ["diff", "@{1}", "HEAD", "--json"], env=_env(root) |
| 405 | ) |
| 406 | |
| 407 | data = json.loads(r.output) |
| 408 | for field in ("added", "modified", "deleted"): |
| 409 | assert field in data, f"missing field: {field!r}" |
| 410 | |
| 411 | def test_diff_at_one_head_shows_changes(self, tmp_path: pathlib.Path) -> None: |
| 412 | """Each commit adds a new file so diff @{1} HEAD should show file2.txt added.""" |
| 413 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3) |
| 414 | |
| 415 | r = runner.invoke( |
| 416 | cli, ["diff", "@{1}", "HEAD", "--json"], env=_env(root) |
| 417 | ) |
| 418 | |
| 419 | data = json.loads(r.output) |
| 420 | # commit-2 added file2.txt; commit-1 is @{1} |
| 421 | assert "file2.txt" in data["added"] |
| 422 | |
| 423 | def test_diff_at_zero_head_is_empty(self, tmp_path: pathlib.Path) -> None: |
| 424 | """Diff between @{0} and HEAD should have no changes (same commit).""" |
| 425 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3) |
| 426 | |
| 427 | r = runner.invoke( |
| 428 | cli, ["diff", "@{0}", "HEAD", "--json"], env=_env(root) |
| 429 | ) |
| 430 | |
| 431 | data = json.loads(r.output) |
| 432 | assert data["total_changes"] == 0 |
| 433 | |
| 434 | |
| 435 | # --------------------------------------------------------------------------- |
| 436 | # RL_27 — End-to-end recovery: commit A → commit B → reset @{1} → back at A |
| 437 | # --------------------------------------------------------------------------- |
| 438 | |
| 439 | class TestEndToEndRecovery: |
| 440 | """RL_27: the full undo-safety-net workflow using @{N} as a commit ref.""" |
| 441 | |
| 442 | def test_reset_at_one_restores_previous_commit( |
| 443 | self, tmp_path: pathlib.Path |
| 444 | ) -> None: |
| 445 | """Commit A, commit B, then muse reset @{1} restores commit A.""" |
| 446 | root, cids = _setup_repo_with_history(tmp_path, n_commits=2) |
| 447 | commit_a = cids[0] # oldest — reflog index 1 |
| 448 | commit_b = cids[1] # newest — reflog index 0 (current HEAD) |
| 449 | |
| 450 | # Sanity: HEAD is currently at commit B. |
| 451 | r = runner.invoke(cli, ["rev-parse", "HEAD", "--json"], env=_env(root)) |
| 452 | assert json.loads(r.output)["commit_id"] == commit_b |
| 453 | |
| 454 | # Recovery step: reset to the previous commit (index 1 = commit A). |
| 455 | r_reset = runner.invoke(cli, ["reset", "@{1}", "--json"], env=_env(root)) |
| 456 | assert r_reset.exit_code == 0, r_reset.output |
| 457 | |
| 458 | # Verify: HEAD is now at commit A. |
| 459 | r_read = runner.invoke(cli, ["read", "--json"], env=_env(root)) |
| 460 | assert r_read.exit_code == 0, r_read.output |
| 461 | data = json.loads(r_read.output) |
| 462 | assert data["commit_id"] == commit_a |
| 463 | |
| 464 | def test_message_at_restored_commit_is_correct( |
| 465 | self, tmp_path: pathlib.Path |
| 466 | ) -> None: |
| 467 | """After muse reset @{1}, muse read shows the message from commit A.""" |
| 468 | root, cids = _setup_repo_with_history(tmp_path, n_commits=2) |
| 469 | |
| 470 | runner.invoke(cli, ["reset", "@{1}"], env=_env(root)) |
| 471 | |
| 472 | r = runner.invoke(cli, ["read", "--json"], env=_env(root)) |
| 473 | data = json.loads(r.output) |
| 474 | assert data["message"] == "commit-0" |
| 475 | |
| 476 | def test_rev_parse_after_reset_shows_old_id( |
| 477 | self, tmp_path: pathlib.Path |
| 478 | ) -> None: |
| 479 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3) |
| 480 | target = cids[0] # will be at @{2} |
| 481 | |
| 482 | runner.invoke(cli, ["reset", "@{2}"], env=_env(root)) |
| 483 | |
| 484 | r = runner.invoke(cli, ["rev-parse", "HEAD", "--json"], env=_env(root)) |
| 485 | assert json.loads(r.output)["commit_id"] == target |
| 486 | |
| 487 | def test_three_commit_chain_recovery(self, tmp_path: pathlib.Path) -> None: |
| 488 | """Commit A → B → C, then reset @{2} restores A.""" |
| 489 | root, cids = _setup_repo_with_history(tmp_path, n_commits=3) |
| 490 | commit_a, commit_b, commit_c = cids |
| 491 | |
| 492 | runner.invoke(cli, ["reset", "@{2}"], env=_env(root)) |
| 493 | |
| 494 | r = runner.invoke(cli, ["rev-parse", "HEAD", "--json"], env=_env(root)) |
| 495 | assert json.loads(r.output)["commit_id"] == commit_a |
| 496 | |
| 497 | |
| 498 | # --------------------------------------------------------------------------- |
| 499 | # Edge cases — resolve_reflog_ref |
| 500 | # --------------------------------------------------------------------------- |
| 501 | |
| 502 | class TestResolveReflogRefUnit: |
| 503 | """Unit tests for the resolve_reflog_ref core function.""" |
| 504 | |
| 505 | def test_returns_none_for_non_at_spec(self, tmp_path: pathlib.Path) -> None: |
| 506 | from muse.core.reflog import resolve_reflog_ref |
| 507 | |
| 508 | root = _make_base_repo(tmp_path) |
| 509 | assert resolve_reflog_ref("main", root) is None |
| 510 | assert resolve_reflog_ref("HEAD", root) is None |
| 511 | assert resolve_reflog_ref("sha256:abc", root) is None |
| 512 | |
| 513 | def test_returns_new_id_for_valid_index(self, tmp_path: pathlib.Path) -> None: |
| 514 | from muse.core.reflog import resolve_reflog_ref |
| 515 | |
| 516 | root, cids = _setup_repo_with_history(tmp_path, n_commits=2) |
| 517 | |
| 518 | result = resolve_reflog_ref("@{0}", root) |
| 519 | assert result == cids[-1] |
| 520 | |
| 521 | def test_raises_file_not_found_when_no_log(self, tmp_path: pathlib.Path) -> None: |
| 522 | from muse.core.reflog import resolve_reflog_ref |
| 523 | |
| 524 | root = _make_base_repo(tmp_path) |
| 525 | |
| 526 | with pytest.raises(FileNotFoundError): |
| 527 | resolve_reflog_ref("@{0}", root) |
| 528 | |
| 529 | def test_raises_index_error_out_of_range(self, tmp_path: pathlib.Path) -> None: |
| 530 | from muse.core.reflog import resolve_reflog_ref |
| 531 | |
| 532 | root, _ = _setup_repo_with_history(tmp_path, n_commits=2) |
| 533 | |
| 534 | with pytest.raises(IndexError) as exc_info: |
| 535 | resolve_reflog_ref("@{99}", root) |
| 536 | idx, total = exc_info.value.args |
| 537 | assert idx == 99 |
| 538 | assert total == 2 |
| 539 | |
| 540 | def test_branch_spec_reads_branch_log(self, tmp_path: pathlib.Path) -> None: |
| 541 | from muse.core.reflog import resolve_reflog_ref |
| 542 | |
| 543 | root, cids = _setup_repo_with_history(tmp_path, n_commits=2, branch="feat") |
| 544 | |
| 545 | result = resolve_reflog_ref("feat@{0}", root) |
| 546 | assert result == cids[-1] |
| 547 | |
| 548 | def test_refs_heads_prefix_stripped(self, tmp_path: pathlib.Path) -> None: |
| 549 | from muse.core.reflog import resolve_reflog_ref |
| 550 | |
| 551 | root, cids = _setup_repo_with_history(tmp_path, n_commits=2, branch="main") |
| 552 | |
| 553 | result = resolve_reflog_ref("refs/heads/main@{0}", root) |
| 554 | assert result == cids[-1] |