test_cmd_branch_copy_reflog.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """TDD tests for muse#48 Phase 1 — ``muse branch -c``/``-C`` reflog copy parity with git. |
| 2 | |
| 3 | ``git branch -c src dst`` copies ``src``'s reflog to ``dst`` and prepends a |
| 4 | "copied from" entry marking the origin. Before this ticket, muse's ``-c`` |
| 5 | wrote only the branch ref — the destination branch had no reflog at all, |
| 6 | even when the source had a rich history. |
| 7 | |
| 8 | Covers BC_01–BC_07. |
| 9 | """ |
| 10 | |
| 11 | from __future__ import annotations |
| 12 | |
| 13 | import pathlib |
| 14 | |
| 15 | import pytest |
| 16 | |
| 17 | from muse.core.paths import reflog_branch_path |
| 18 | from muse.core.reflog import read_reflog |
| 19 | from muse.core.types import NULL_COMMIT_ID |
| 20 | from tests.test_cmd_branch import _branch, _commit, _invoke, repo # noqa: F401 |
| 21 | |
| 22 | |
| 23 | class TestCopyReflogBasic: |
| 24 | def test_bc01_log_copied_byte_for_byte_plus_new_entry(self, repo: pathlib.Path) -> None: |
| 25 | """BC_01: the copy is byte-for-byte identical to the source except |
| 26 | for the new entry. Reflog files are oldest-first on disk (newest |
| 27 | read back as @{0} only after read_reflog reverses them), so the new |
| 28 | entry is appended after the copied bytes, not prepended before them.""" |
| 29 | _branch(repo, "dev") |
| 30 | _invoke(repo, ["checkout", "dev"]) |
| 31 | (repo / "x.py").write_text("x = 1\n") |
| 32 | _commit(repo, "-m", "dev commit") |
| 33 | _invoke(repo, ["checkout", "main"]) |
| 34 | |
| 35 | src_log_path = reflog_branch_path(repo, "dev") |
| 36 | src_bytes = src_log_path.read_bytes() |
| 37 | |
| 38 | result = _branch(repo, "-c", "dev", "feat/x") |
| 39 | assert result.exit_code == 0 |
| 40 | |
| 41 | dst_log_path = reflog_branch_path(repo, "feat/x") |
| 42 | dst_bytes = dst_log_path.read_bytes() |
| 43 | |
| 44 | # Destination log = source log (verbatim) + [new line] appended. |
| 45 | assert dst_bytes.startswith(src_bytes) |
| 46 | appended = dst_bytes[len(src_bytes):] |
| 47 | assert appended.count(b"\n") == 1 |
| 48 | |
| 49 | def test_bc02_prepended_entry_fields(self, repo: pathlib.Path) -> None: |
| 50 | """BC_02: the prepended entry has operation 'branch: copied from |
| 51 | <src>', new_id = tip of src, old_id = null ID.""" |
| 52 | _branch(repo, "dev") |
| 53 | tip = _invoke(repo, ["rev-parse", "dev"]).output.strip() |
| 54 | |
| 55 | _branch(repo, "-c", "dev", "feat/y") |
| 56 | |
| 57 | entries = read_reflog(repo, branch="feat/y", limit=10) |
| 58 | assert entries[0].operation == "branch: copied from dev" |
| 59 | assert entries[0].new_id == tip |
| 60 | assert entries[0].old_id == NULL_COMMIT_ID |
| 61 | |
| 62 | def test_bc03_reflog_cli_shows_prepended_then_source_history( |
| 63 | self, repo: pathlib.Path |
| 64 | ) -> None: |
| 65 | """BC_03: ``muse reflog --branch dst`` returns the prepended entry at |
| 66 | index 0, source history following.""" |
| 67 | _branch(repo, "dev") |
| 68 | _invoke(repo, ["checkout", "dev"]) |
| 69 | (repo / "a.py").write_text("a = 1\n") |
| 70 | _commit(repo, "-m", "dev change") |
| 71 | _invoke(repo, ["checkout", "main"]) |
| 72 | |
| 73 | src_entries = read_reflog(repo, branch="dev", limit=100) |
| 74 | _branch(repo, "-c", "dev", "backup/dev") |
| 75 | dst_entries = read_reflog(repo, branch="backup/dev", limit=100) |
| 76 | |
| 77 | assert dst_entries[0].operation == "branch: copied from dev" |
| 78 | assert [e.operation for e in dst_entries[1:]] == [e.operation for e in src_entries] |
| 79 | |
| 80 | def test_bc04_force_copy_replaces_destination_log(self, repo: pathlib.Path) -> None: |
| 81 | """BC_04: force-copy (-C) replaces the destination's reflog wholesale |
| 82 | — the previous destination log is not preserved.""" |
| 83 | _branch(repo, "src") |
| 84 | _branch(repo, "dst") |
| 85 | _invoke(repo, ["checkout", "dst"]) |
| 86 | (repo / "dst-only.py").write_text("unique = True\n") |
| 87 | _commit(repo, "-m", "dst-only commit") |
| 88 | _invoke(repo, ["checkout", "main"]) |
| 89 | |
| 90 | src_entries = read_reflog(repo, branch="src", limit=100) |
| 91 | |
| 92 | _branch(repo, "-C", "src", "dst") |
| 93 | dst_entries_after = read_reflog(repo, branch="dst", limit=100) |
| 94 | |
| 95 | # The dst-only commit entry must be gone — wholesale replacement, |
| 96 | # not a merge of old and new history. |
| 97 | assert all("dst-only" not in e.operation for e in dst_entries_after) |
| 98 | assert dst_entries_after[0].operation == "branch: copied from src" |
| 99 | assert len(dst_entries_after) == len(src_entries) + 1 |
| 100 | |
| 101 | def test_bc05_source_with_no_reflog_writes_just_origin_entry( |
| 102 | self, tmp_path: pathlib.Path |
| 103 | ) -> None: |
| 104 | """BC_05: if the source branch has no reflog, copy writes just the |
| 105 | single origin entry with no prior history.""" |
| 106 | import os |
| 107 | |
| 108 | saved = os.getcwd() |
| 109 | try: |
| 110 | os.chdir(tmp_path) |
| 111 | from tests.cli_test_helper import CliRunner |
| 112 | CliRunner().invoke(None, ["init"]) |
| 113 | finally: |
| 114 | os.chdir(saved) |
| 115 | |
| 116 | # "main" exists with no commits yet — no reflog file. |
| 117 | src_log = reflog_branch_path(tmp_path, "main") |
| 118 | assert not src_log.exists() |
| 119 | |
| 120 | result = _branch(tmp_path, "-c", "main", "copy-of-main") |
| 121 | # No commits yet means no tip to copy — branch -c with an empty |
| 122 | # source produces an empty destination branch and no reflog either |
| 123 | # (there is no tip to record a "copied from" entry about). |
| 124 | assert result.exit_code == 0 |
| 125 | dst_entries = read_reflog(tmp_path, branch="copy-of-main", limit=10) |
| 126 | assert dst_entries == [] |
| 127 | |
| 128 | def test_bc05b_source_with_reflog_but_dest_from_scratch(self, repo: pathlib.Path) -> None: |
| 129 | """Companion to BC_05: a source *with* a reflog but freshly copied |
| 130 | produces exactly source-entries + 1, with no leftover state.""" |
| 131 | _branch(repo, "dev") |
| 132 | src_entries = read_reflog(repo, branch="dev", limit=100) |
| 133 | assert len(src_entries) == 1 # just "created from main" |
| 134 | |
| 135 | _branch(repo, "-c", "dev", "fresh/copy") |
| 136 | dst_entries = read_reflog(repo, branch="fresh/copy", limit=100) |
| 137 | assert len(dst_entries) == len(src_entries) + 1 |
| 138 | |
| 139 | def test_bc06_ref_written_before_reflog_copy_is_safe_if_interrupted( |
| 140 | self, repo: pathlib.Path |
| 141 | ) -> None: |
| 142 | """BC_06: if interrupted after the ref write but before the log |
| 143 | write, the branch is still valid (just missing the log) — simulated |
| 144 | by writing only the ref and confirming read_reflog degrades |
| 145 | gracefully rather than raising.""" |
| 146 | from muse.core.refs import write_branch_ref, get_head_commit_id |
| 147 | |
| 148 | tip = get_head_commit_id(repo, "main") |
| 149 | write_branch_ref(repo, "half-copied", tip) |
| 150 | # No reflog written yet — simulates an interruption between steps. |
| 151 | entries = read_reflog(repo, branch="half-copied", limit=10) |
| 152 | assert entries == [] |
| 153 | |
| 154 | def test_bc07_integration_five_entries_copy_yields_six(self, repo: pathlib.Path) -> None: |
| 155 | """BC_07: a branch with N reflog entries -> muse branch -c -> the |
| 156 | destination shows N+1 entries (source history + the copy marker).""" |
| 157 | _branch(repo, "dev") |
| 158 | _invoke(repo, ["checkout", "dev"]) |
| 159 | for i in range(4): |
| 160 | (repo / f"f{i}.py").write_text(f"v = {i}\n") |
| 161 | _commit(repo, "-m", f"change {i}") |
| 162 | _invoke(repo, ["checkout", "main"]) |
| 163 | |
| 164 | src_entries = read_reflog(repo, branch="dev", limit=100) |
| 165 | n = len(src_entries) |
| 166 | |
| 167 | result = _branch(repo, "-c", "dev", "backup/dev") |
| 168 | assert result.exit_code == 0 |
| 169 | |
| 170 | dst_entries = read_reflog(repo, branch="backup/dev", limit=100) |
| 171 | assert len(dst_entries) == n + 1 |