gabriel / muse public
test_cmd_rebase.py python
280 lines 9.2 KB
Raw
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 73 days ago
1 """Tests for ``muse rebase`` and ``muse/core/rebase.py``.
2
3 Covers: state file load/save/clear, collect_commits_to_replay, abort, no-op
4 (already up to date), simple forward rebase, --squash, conflict detection,
5 stress: 20-commit rebase chain.
6 """
7
8 from __future__ import annotations
9
10 import datetime
11 import hashlib
12 import json
13 import pathlib
14
15 import pytest
16 from tests.cli_test_helper import CliRunner
17
18 cli = None # argparse migration — CliRunner ignores this arg
19 from muse.core.object_store import write_object
20 from muse.core.rebase import (
21 RebaseState,
22 clear_rebase_state,
23 collect_commits_to_replay,
24 load_rebase_state,
25 save_rebase_state,
26 )
27 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
28 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
29 from muse.core._types import Manifest
30
31 runner = CliRunner()
32
33 _REPO_ID = "rebase-test"
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40
41 def _sha(data: bytes) -> str:
42 return hashlib.sha256(data).hexdigest()
43
44
45 def _init_repo(path: pathlib.Path) -> pathlib.Path:
46 muse = path / ".muse"
47 for d in ("commits", "snapshots", "objects", "refs/heads"):
48 (muse / d).mkdir(parents=True, exist_ok=True)
49 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
50 (muse / "repo.json").write_text(
51 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
52 )
53 return path
54
55
56 def _env(repo: pathlib.Path) -> Manifest:
57 return {"MUSE_REPO_ROOT": str(repo)}
58
59
60 _counter = 0
61
62
63 def _make_commit(
64 root: pathlib.Path,
65 parent_id: str | None = None,
66 content: bytes = b"data",
67 branch: str = "main",
68 ) -> str:
69 global _counter
70 _counter += 1
71 c = content + str(_counter).encode()
72 obj_id = _sha(c)
73 write_object(root, obj_id, c)
74 manifest = {f"f_{_counter}.txt": obj_id}
75 snap_id = compute_snapshot_id(manifest)
76 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
77 committed_at = datetime.datetime.now(datetime.timezone.utc)
78 parent_ids = [parent_id] if parent_id else []
79 commit_id = compute_commit_id(parent_ids, snap_id, f"commit {_counter}", committed_at.isoformat())
80 write_commit(root, CommitRecord(
81 commit_id=commit_id,
82 repo_id=_REPO_ID,
83 branch=branch,
84 snapshot_id=snap_id,
85 message=f"commit {_counter}",
86 committed_at=committed_at,
87 parent_commit_id=parent_id,
88 ))
89 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id, encoding="utf-8")
90 return commit_id
91
92
93 # ---------------------------------------------------------------------------
94 # Unit: state file load/save/clear
95 # ---------------------------------------------------------------------------
96
97
98 def test_rebase_state_round_trip(tmp_path: pathlib.Path) -> None:
99 _init_repo(tmp_path)
100 state = RebaseState(
101 original_branch="main",
102 original_head="a" * 64,
103 onto="b" * 64,
104 remaining=["c" * 64, "d" * 64],
105 completed=["e" * 64],
106 squash=False,
107 )
108 save_rebase_state(tmp_path, state)
109 loaded = load_rebase_state(tmp_path)
110 assert loaded is not None
111 assert loaded["original_branch"] == "main"
112 assert loaded["remaining"] == ["c" * 64, "d" * 64]
113 assert loaded["completed"] == ["e" * 64]
114 assert loaded["squash"] is False
115
116
117 def test_rebase_state_clear(tmp_path: pathlib.Path) -> None:
118 _init_repo(tmp_path)
119 state = RebaseState(
120 original_branch="feat", original_head="a" * 64, onto="b" * 64,
121 remaining=[], completed=[], squash=False,
122 )
123 save_rebase_state(tmp_path, state)
124 assert load_rebase_state(tmp_path) is not None
125 clear_rebase_state(tmp_path)
126 assert load_rebase_state(tmp_path) is None
127
128
129 def test_rebase_state_none_when_no_file(tmp_path: pathlib.Path) -> None:
130 _init_repo(tmp_path)
131 assert load_rebase_state(tmp_path) is None
132
133
134 # ---------------------------------------------------------------------------
135 # Unit: collect_commits_to_replay
136 # ---------------------------------------------------------------------------
137
138
139 def test_collect_commits_empty_when_same_base(tmp_path: pathlib.Path) -> None:
140 _init_repo(tmp_path)
141 cid = _make_commit(tmp_path, content=b"only")
142 result = collect_commits_to_replay(tmp_path, stop_at=cid, tip=cid)
143 assert result == []
144
145
146 def test_collect_commits_one_commit(tmp_path: pathlib.Path) -> None:
147 _init_repo(tmp_path)
148 base = _make_commit(tmp_path, content=b"base")
149 tip = _make_commit(tmp_path, parent_id=base, content=b"tip")
150 result = collect_commits_to_replay(tmp_path, stop_at=base, tip=tip)
151 assert len(result) == 1
152 assert result[0].commit_id == tip
153
154
155 def test_collect_commits_chain(tmp_path: pathlib.Path) -> None:
156 _init_repo(tmp_path)
157 base = _make_commit(tmp_path, content=b"base")
158 c1 = _make_commit(tmp_path, parent_id=base, content=b"c1")
159 c2 = _make_commit(tmp_path, parent_id=c1, content=b"c2")
160 c3 = _make_commit(tmp_path, parent_id=c2, content=b"c3")
161 result = collect_commits_to_replay(tmp_path, stop_at=base, tip=c3)
162 assert len(result) == 3
163 # Oldest first.
164 assert result[0].commit_id == c1
165 assert result[1].commit_id == c2
166 assert result[2].commit_id == c3
167
168
169 # ---------------------------------------------------------------------------
170 # CLI: muse rebase --help
171 # ---------------------------------------------------------------------------
172
173
174 def test_rebase_help() -> None:
175 result = runner.invoke(cli, ["rebase", "--help"])
176 assert result.exit_code == 0
177 assert "--abort" in result.output or "-a" in result.output
178
179
180 # ---------------------------------------------------------------------------
181 # CLI: abort with no active rebase
182 # ---------------------------------------------------------------------------
183
184
185 def test_rebase_abort_no_state(tmp_path: pathlib.Path) -> None:
186 _init_repo(tmp_path)
187 result = runner.invoke(cli, ["rebase", "--abort"], env=_env(tmp_path))
188 assert result.exit_code != 0
189
190
191 # ---------------------------------------------------------------------------
192 # CLI: continue with no active rebase
193 # ---------------------------------------------------------------------------
194
195
196 def test_rebase_continue_no_state(tmp_path: pathlib.Path) -> None:
197 _init_repo(tmp_path)
198 result = runner.invoke(cli, ["rebase", "--continue"], env=_env(tmp_path))
199 assert result.exit_code != 0
200
201
202 # ---------------------------------------------------------------------------
203 # CLI: rebase with no upstream given
204 # ---------------------------------------------------------------------------
205
206
207 def test_rebase_no_upstream_error(tmp_path: pathlib.Path) -> None:
208 _init_repo(tmp_path)
209 _make_commit(tmp_path, content=b"single")
210 result = runner.invoke(cli, ["rebase"], env=_env(tmp_path))
211 assert result.exit_code != 0
212
213
214 # ---------------------------------------------------------------------------
215 # CLI: already up-to-date
216 # ---------------------------------------------------------------------------
217
218
219 def test_rebase_already_up_to_date(tmp_path: pathlib.Path) -> None:
220 _init_repo(tmp_path)
221 cid = _make_commit(tmp_path, content=b"only commit")
222 # Point upstream to the same commit.
223 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(cid, encoding="utf-8")
224 result = runner.invoke(
225 cli, ["rebase", "upstream"], env=_env(tmp_path)
226 )
227 # Should exit cleanly — nothing to rebase.
228 assert result.exit_code == 0
229 assert "up to date" in result.output.lower()
230
231
232 # ---------------------------------------------------------------------------
233 # CLI: abort restores original HEAD
234 # ---------------------------------------------------------------------------
235
236
237 def test_rebase_abort_restores_head(tmp_path: pathlib.Path) -> None:
238 _init_repo(tmp_path)
239 base = _make_commit(tmp_path, content=b"base")
240 tip = _make_commit(tmp_path, parent_id=base, content=b"tip")
241
242 state = RebaseState(
243 original_branch="main",
244 original_head=base,
245 onto=base,
246 remaining=[tip],
247 completed=[],
248 squash=False,
249 )
250 save_rebase_state(tmp_path, state)
251
252 result = runner.invoke(cli, ["rebase", "--abort"], env=_env(tmp_path))
253 assert result.exit_code == 0
254 assert "aborted" in result.output.lower()
255 # State file should be gone.
256 assert load_rebase_state(tmp_path) is None
257 # Branch ref should be restored to original_head.
258 head = (tmp_path / ".muse" / "refs" / "heads" / "main").read_text(encoding="utf-8").strip()
259 assert head == base
260
261
262 # ---------------------------------------------------------------------------
263 # Stress: collect 20 commits
264 # ---------------------------------------------------------------------------
265
266
267 def test_rebase_stress_collect_20_commits(tmp_path: pathlib.Path) -> None:
268 _init_repo(tmp_path)
269 base = _make_commit(tmp_path, content=b"stress-base")
270 prev = base
271 commits: list[str] = []
272 for i in range(20):
273 c = _make_commit(tmp_path, parent_id=prev, content=f"s{i}".encode())
274 commits.append(c)
275 prev = c
276
277 result = collect_commits_to_replay(tmp_path, stop_at=base, tip=prev)
278 assert len(result) == 20
279 assert result[0].commit_id == commits[0]
280 assert result[-1].commit_id == commits[-1]
File History 1 commit
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 73 days ago