gabriel / muse public
test_rebase_supercharge.py python
997 lines 42.2 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """Supercharged tests for ``muse rebase`` — TDD for all gaps.
2
3 Covers every JSON output path for:
4 - ``duration_ms`` (float, milliseconds)
5 - ``exit_code`` (int, 0/1/3)
6 - ``replayed_commit_ids`` (list[str], sha256:-prefixed)
7
8 Covers sha256:-prefix correctness:
9 - ``_resolve_ref_to_id`` with sha256:-prefixed content in ref files
10 - ``_short_id`` keeps the sha256: prefix and truncates only the hex portion
11 - ``new_head``/``onto`` in JSON are sha256:-prefixed
12
13 Covers all integration and lifecycle paths:
14 - completed (normal), aborted, up_to_date, conflict, dry_run, status, squash
15
16 Security, performance, and stress:
17 - symlink guard on REBASE_STATE.json (load, save, clear)
18 - size cap on REBASE_STATE.json
19 - 50-commit dry-run, concurrent status reads
20 """
21
22 from __future__ import annotations
23
24 import datetime
25 import hashlib
26 import json
27 import pathlib
28 import threading
29 import time
30
31 import pytest
32
33 from tests.cli_test_helper import CliRunner
34 from muse.core.object_store import write_object
35 from muse.core.rebase import (
36 RebaseState,
37 _MAX_STATE_BYTES,
38 _REBASE_STATE_FILE,
39 clear_rebase_state,
40 collect_commits_to_replay,
41 get_rebase_progress,
42 load_rebase_state,
43 save_rebase_state,
44 )
45 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
46 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
47 from muse.core._types import Manifest, long_id, short_id
48
49 runner = CliRunner()
50 _REPO_ID = "rebase-supercharge-test"
51
52
53 # ---------------------------------------------------------------------------
54 # Helpers
55 # ---------------------------------------------------------------------------
56
57
58 def _oid(content: bytes) -> str:
59 """Return a sha256:-prefixed object ID."""
60 return long_id(hashlib.sha256(content).hexdigest())
61
62
63 _counter = 0
64 _counter_lock = threading.Lock()
65
66
67 def _make_commit(
68 root: pathlib.Path,
69 parent_id: str | None = None,
70 content: bytes = b"data",
71 branch: str = "main",
72 ) -> str:
73 """Create a commit with correct sha256:-prefixed object IDs. Returns the commit ID."""
74 global _counter
75 with _counter_lock:
76 _counter += 1
77 c_val = _counter
78 c = content + str(c_val).encode()
79 obj_id = _oid(c)
80 write_object(root, obj_id, c)
81 manifest: Manifest = {f"f_{c_val}.txt": obj_id}
82 snap_id = compute_snapshot_id(manifest)
83 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
84 committed_at = datetime.datetime.now(datetime.timezone.utc)
85 parent_ids = [parent_id] if parent_id else []
86 commit_id = compute_commit_id(parent_ids, snap_id, f"commit {c_val}", committed_at.isoformat())
87 write_commit(root, CommitRecord(
88 commit_id=commit_id,
89 repo_id=_REPO_ID,
90 branch=branch,
91 snapshot_id=snap_id,
92 message=f"commit {c_val}",
93 committed_at=committed_at,
94 parent_commit_id=parent_id,
95 ))
96 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id, encoding="utf-8")
97 return commit_id
98
99
100 def _init_repo(path: pathlib.Path) -> pathlib.Path:
101 muse = path / ".muse"
102 for d in ("commits", "snapshots", "objects", "refs/heads"):
103 (muse / d).mkdir(parents=True, exist_ok=True)
104 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
105 (muse / "repo.json").write_text(
106 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
107 )
108 return path
109
110
111 def _env(repo: pathlib.Path) -> dict[str, str]:
112 return {"MUSE_REPO_ROOT": str(repo)}
113
114
115 def _invoke(args: list[str], repo: pathlib.Path):
116 return runner.invoke(None, args, env=_env(repo))
117
118
119 def _json_from(output: str) -> dict:
120 for line in output.splitlines():
121 line = line.strip()
122 if line.startswith("{"):
123 return json.loads(line)
124 return json.loads(output.strip())
125
126
127 # ---------------------------------------------------------------------------
128 # _short_id helper — prefix is canonical, only hex portion is truncated
129 # ---------------------------------------------------------------------------
130
131
132 class TestShortId:
133 """_short_id keeps the sha256: prefix and truncates only the hex portion."""
134
135 def test_short_id_keeps_prefix(self, tmp_path: pathlib.Path) -> None:
136 """_short_id must keep the sha256: prefix — it is canonical in Muse."""
137
138 cid = long_id("a" * 64)
139 result = short_id(cid)
140 assert result.startswith("sha256:"), f"Expected sha256: prefix, got {result!r}"
141
142 def test_short_id_truncates_hex_to_12(self, tmp_path: pathlib.Path) -> None:
143 """_short_id returns sha256: + first 12 hex chars."""
144
145 cid = long_id("deadbeef" * 8)
146 result = short_id(cid)
147 assert result == "sha256:deadbeef" + "dead" # prefix + 12 hex chars
148
149 def test_short_id_total_length(self, tmp_path: pathlib.Path) -> None:
150 """sha256: (7) + 12 hex chars = 19 total chars."""
151
152 cid = long_id("cafebabe" * 8)
153 result = short_id(cid)
154 assert len(result) == 19 # "sha256:" (7) + 12 hex chars
155
156 def test_short_id_bare_hex_passthrough(self, tmp_path: pathlib.Path) -> None:
157 """_short_id with a bare hex string (no prefix) returns first 12 chars."""
158
159 bare = "1234567890ab" + "cd" * 26 # 64 chars total
160 result = short_id(bare)
161 assert result == "1234567890ab"
162
163 def test_text_output_shows_sha256_short_id(self, tmp_path: pathlib.Path) -> None:
164 """Text output must show sha256:<12 hex chars> short IDs, not bare hex."""
165 _init_repo(tmp_path)
166 base = _make_commit(tmp_path, content=b"base")
167 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
168 c1 = _make_commit(tmp_path, parent_id=base, content=b"c1")
169 result = _invoke(["rebase", "--dry-run", "upstream"], tmp_path)
170 assert result.exit_code == 0
171 # The output must contain sha256:<first 12 hex chars of c1>
172 expected_short = long_id(c1[7:19])# prefix + 12 hex chars
173 assert expected_short in result.output, (
174 f"Expected {expected_short!r} in dry-run text output.\n"
175 f"Got: {result.output!r}"
176 )
177
178
179 # ---------------------------------------------------------------------------
180 # _resolve_ref_to_id — must handle sha256:-prefixed content in ref files
181 # ---------------------------------------------------------------------------
182
183
184 class TestResolveRefToId:
185 """_resolve_ref_to_id must handle ref files whose content has sha256: prefix."""
186
187 def test_resolves_sha256_prefixed_ref_file(self, tmp_path: pathlib.Path) -> None:
188 """Bug: len(raw) == 64 check fails when ref file contains sha256:-prefixed ID (71 chars)."""
189 from muse.cli.commands.rebase import _resolve_ref_to_id
190 _init_repo(tmp_path)
191 commit_id = _make_commit(tmp_path, content=b"sha256-prefix-test")
192 # commit_id is sha256:-prefixed (71 chars) — the ref file already has this
193 resolved = _resolve_ref_to_id(tmp_path, _REPO_ID, "main", "main")
194 assert resolved == commit_id, (
195 f"Expected {commit_id!r}, got {resolved!r}. "
196 "Bug: _resolve_ref_to_id len check fails for sha256:-prefixed IDs."
197 )
198
199 def test_resolves_head(self, tmp_path: pathlib.Path) -> None:
200 """HEAD resolves to the current branch's commit."""
201 from muse.cli.commands.rebase import _resolve_ref_to_id
202 _init_repo(tmp_path)
203 commit_id = _make_commit(tmp_path, content=b"head-test")
204 result = _resolve_ref_to_id(tmp_path, _REPO_ID, "main", "HEAD")
205 assert result == commit_id
206
207 def test_returns_none_for_missing_branch(self, tmp_path: pathlib.Path) -> None:
208 """Unknown branch name resolves to None."""
209 from muse.cli.commands.rebase import _resolve_ref_to_id
210 _init_repo(tmp_path)
211 _make_commit(tmp_path)
212 result = _resolve_ref_to_id(tmp_path, _REPO_ID, "main", "nonexistent-branch")
213 assert result is None
214
215 def test_resolved_id_is_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
216 """The resolved commit ID must be sha256:-prefixed."""
217 from muse.cli.commands.rebase import _resolve_ref_to_id
218 _init_repo(tmp_path)
219 _make_commit(tmp_path, content=b"prefix-check")
220 result = _resolve_ref_to_id(tmp_path, _REPO_ID, "main", "main")
221 assert result is not None
222 assert result.startswith("sha256:")
223
224
225 # ---------------------------------------------------------------------------
226 # duration_ms — all JSON output paths must include it
227 # ---------------------------------------------------------------------------
228
229
230 class TestJsonSchemaDurationMs:
231 """Every JSON output path must include duration_ms."""
232
233 def test_status_inactive_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
234 _init_repo(tmp_path)
235 _make_commit(tmp_path)
236 result = _invoke(["rebase", "--status", "--json"], tmp_path)
237 assert result.exit_code == 0
238 data = _json_from(result.output)
239 assert "duration_ms" in data, f"Missing duration_ms in status JSON: {data}"
240 assert isinstance(data["duration_ms"], (int, float))
241 assert data["duration_ms"] >= 0
242
243 def test_status_active_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
244 _init_repo(tmp_path)
245 state = RebaseState(
246 original_branch="main", original_head="a" * 64, onto="b" * 64,
247 remaining=["c" * 64], completed=[], squash=False,
248 )
249 save_rebase_state(tmp_path, state)
250 result = _invoke(["rebase", "--status", "--json"], tmp_path)
251 assert result.exit_code == 0
252 data = _json_from(result.output)
253 assert "duration_ms" in data
254 assert isinstance(data["duration_ms"], (int, float))
255
256 def test_abort_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
257 _init_repo(tmp_path)
258 base = _make_commit(tmp_path)
259 state = RebaseState(
260 original_branch="main", original_head=base, onto=base,
261 remaining=[], completed=[], squash=False,
262 )
263 save_rebase_state(tmp_path, state)
264 result = _invoke(["rebase", "--abort", "--json"], tmp_path)
265 assert result.exit_code == 0, result.output
266 data = _json_from(result.output)
267 assert "duration_ms" in data, f"Missing duration_ms in abort JSON: {data}"
268 assert isinstance(data["duration_ms"], (int, float))
269
270 def test_up_to_date_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
271 _init_repo(tmp_path)
272 cid = _make_commit(tmp_path)
273 (tmp_path / ".muse" / "refs" / "heads" / "up").write_text(cid, encoding="utf-8")
274 result = _invoke(["rebase", "--json", "up"], tmp_path)
275 assert result.exit_code == 0, result.output
276 data = _json_from(result.output)
277 assert "duration_ms" in data, f"Missing duration_ms in up_to_date JSON: {data}"
278 assert isinstance(data["duration_ms"], (int, float))
279
280 def test_dry_run_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
281 _init_repo(tmp_path)
282 base = _make_commit(tmp_path)
283 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
284 _make_commit(tmp_path, parent_id=base)
285 result = _invoke(["rebase", "--dry-run", "--json", "upstream"], tmp_path)
286 assert result.exit_code == 0, result.output
287 data = _json_from(result.output)
288 assert "duration_ms" in data, f"Missing duration_ms in dry_run JSON: {data}"
289 assert isinstance(data["duration_ms"], (int, float))
290
291 def test_completed_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
292 _init_repo(tmp_path)
293 base = _make_commit(tmp_path)
294 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
295 _make_commit(tmp_path, parent_id=base)
296 result = _invoke(["rebase", "--json", "upstream"], tmp_path)
297 assert result.exit_code == 0, result.output
298 data = _json_from(result.output)
299 assert "duration_ms" in data, f"Missing duration_ms in completed JSON: {data}"
300 assert isinstance(data["duration_ms"], (int, float))
301
302
303 # ---------------------------------------------------------------------------
304 # exit_code — all JSON output paths must include it
305 # ---------------------------------------------------------------------------
306
307
308 class TestJsonSchemaExitCode:
309 """Every JSON output path must include exit_code."""
310
311 def test_status_json_has_exit_code_0(self, tmp_path: pathlib.Path) -> None:
312 _init_repo(tmp_path)
313 _make_commit(tmp_path)
314 result = _invoke(["rebase", "--status", "--json"], tmp_path)
315 assert result.exit_code == 0
316 data = _json_from(result.output)
317 assert "exit_code" in data, f"Missing exit_code: {data}"
318 assert data["exit_code"] == 0
319
320 def test_abort_json_has_exit_code_0(self, tmp_path: pathlib.Path) -> None:
321 _init_repo(tmp_path)
322 base = _make_commit(tmp_path)
323 state = RebaseState(
324 original_branch="main", original_head=base, onto=base,
325 remaining=[], completed=[], squash=False,
326 )
327 save_rebase_state(tmp_path, state)
328 result = _invoke(["rebase", "--abort", "--json"], tmp_path)
329 assert result.exit_code == 0, result.output
330 data = _json_from(result.output)
331 assert "exit_code" in data, f"Missing exit_code: {data}"
332 assert data["exit_code"] == 0
333
334 def test_up_to_date_json_has_exit_code_0(self, tmp_path: pathlib.Path) -> None:
335 _init_repo(tmp_path)
336 cid = _make_commit(tmp_path)
337 (tmp_path / ".muse" / "refs" / "heads" / "up").write_text(cid, encoding="utf-8")
338 result = _invoke(["rebase", "--json", "up"], tmp_path)
339 assert result.exit_code == 0, result.output
340 data = _json_from(result.output)
341 assert "exit_code" in data
342 assert data["exit_code"] == 0
343
344 def test_dry_run_json_has_exit_code_0(self, tmp_path: pathlib.Path) -> None:
345 _init_repo(tmp_path)
346 base = _make_commit(tmp_path)
347 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
348 _make_commit(tmp_path, parent_id=base)
349 result = _invoke(["rebase", "--dry-run", "--json", "upstream"], tmp_path)
350 assert result.exit_code == 0, result.output
351 data = _json_from(result.output)
352 assert "exit_code" in data
353 assert data["exit_code"] == 0
354
355 def test_completed_json_has_exit_code_0(self, tmp_path: pathlib.Path) -> None:
356 _init_repo(tmp_path)
357 base = _make_commit(tmp_path)
358 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
359 _make_commit(tmp_path, parent_id=base)
360 result = _invoke(["rebase", "--json", "upstream"], tmp_path)
361 assert result.exit_code == 0, result.output
362 data = _json_from(result.output)
363 assert "exit_code" in data
364 assert data["exit_code"] == 0
365
366 def test_duration_ms_is_nonnegative_float(self, tmp_path: pathlib.Path) -> None:
367 """duration_ms must be a non-negative number."""
368 _init_repo(tmp_path)
369 cid = _make_commit(tmp_path)
370 (tmp_path / ".muse" / "refs" / "heads" / "up").write_text(cid, encoding="utf-8")
371 result = _invoke(["rebase", "--json", "up"], tmp_path)
372 data = _json_from(result.output)
373 assert data["duration_ms"] >= 0.0
374
375
376 # ---------------------------------------------------------------------------
377 # replayed_commit_ids — completed result JSON must list new commit IDs
378 # ---------------------------------------------------------------------------
379
380
381 class TestReplayedCommitIds:
382 """Completed rebase JSON must include replayed_commit_ids."""
383
384 def test_completed_has_replayed_commit_ids(self, tmp_path: pathlib.Path) -> None:
385 _init_repo(tmp_path)
386 base = _make_commit(tmp_path)
387 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
388 _make_commit(tmp_path, parent_id=base)
389 result = _invoke(["rebase", "--json", "upstream"], tmp_path)
390 assert result.exit_code == 0, result.output
391 data = _json_from(result.output)
392 assert "replayed_commit_ids" in data, f"Missing replayed_commit_ids: {data}"
393 assert isinstance(data["replayed_commit_ids"], list)
394
395 def test_replayed_commit_ids_count_matches_replayed(self, tmp_path: pathlib.Path) -> None:
396 _init_repo(tmp_path)
397 base = _make_commit(tmp_path)
398 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
399 c1 = _make_commit(tmp_path, parent_id=base)
400 c2 = _make_commit(tmp_path, parent_id=c1)
401 result = _invoke(["rebase", "--json", "upstream"], tmp_path)
402 assert result.exit_code == 0, result.output
403 data = _json_from(result.output)
404 assert data["replayed"] == 2
405 assert len(data["replayed_commit_ids"]) == 2
406
407 def test_replayed_commit_ids_are_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
408 _init_repo(tmp_path)
409 base = _make_commit(tmp_path)
410 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
411 _make_commit(tmp_path, parent_id=base)
412 result = _invoke(["rebase", "--json", "upstream"], tmp_path)
413 assert result.exit_code == 0, result.output
414 data = _json_from(result.output)
415 for cid in data["replayed_commit_ids"]:
416 assert cid.startswith("sha256:"), f"Not sha256:-prefixed: {cid!r}"
417
418 def test_abort_has_replayed_commit_ids_empty(self, tmp_path: pathlib.Path) -> None:
419 """Aborted rebase has no new commits — replayed_commit_ids must be empty list."""
420 _init_repo(tmp_path)
421 base = _make_commit(tmp_path)
422 state = RebaseState(
423 original_branch="main", original_head=base, onto=base,
424 remaining=[], completed=[], squash=False,
425 )
426 save_rebase_state(tmp_path, state)
427 result = _invoke(["rebase", "--abort", "--json"], tmp_path)
428 assert result.exit_code == 0, result.output
429 data = _json_from(result.output)
430 assert "replayed_commit_ids" in data
431 assert data["replayed_commit_ids"] == []
432
433 def test_up_to_date_has_replayed_commit_ids_empty(self, tmp_path: pathlib.Path) -> None:
434 _init_repo(tmp_path)
435 cid = _make_commit(tmp_path)
436 (tmp_path / ".muse" / "refs" / "heads" / "up").write_text(cid, encoding="utf-8")
437 result = _invoke(["rebase", "--json", "up"], tmp_path)
438 assert result.exit_code == 0, result.output
439 data = _json_from(result.output)
440 assert "replayed_commit_ids" in data
441 assert data["replayed_commit_ids"] == []
442
443
444 # ---------------------------------------------------------------------------
445 # Data integrity — IDs in JSON must be sha256:-prefixed
446 # ---------------------------------------------------------------------------
447
448
449 class TestDataIntegrity:
450 """All commit IDs in JSON output must be sha256:-prefixed."""
451
452 def test_new_head_is_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
453 _init_repo(tmp_path)
454 base = _make_commit(tmp_path)
455 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
456 _make_commit(tmp_path, parent_id=base)
457 result = _invoke(["rebase", "--json", "upstream"], tmp_path)
458 assert result.exit_code == 0, result.output
459 data = _json_from(result.output)
460 assert data["new_head"].startswith("sha256:"), f"new_head not sha256:-prefixed: {data['new_head']!r}"
461
462 def test_onto_is_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
463 _init_repo(tmp_path)
464 base = _make_commit(tmp_path)
465 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
466 _make_commit(tmp_path, parent_id=base)
467 result = _invoke(["rebase", "--json", "upstream"], tmp_path)
468 assert result.exit_code == 0, result.output
469 data = _json_from(result.output)
470 assert data["onto"].startswith("sha256:"), f"onto not sha256:-prefixed: {data['onto']!r}"
471
472 def test_up_to_date_new_head_is_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
473 _init_repo(tmp_path)
474 cid = _make_commit(tmp_path)
475 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(cid, encoding="utf-8")
476 result = _invoke(["rebase", "--json", "upstream"], tmp_path)
477 assert result.exit_code == 0, result.output
478 data = _json_from(result.output)
479 assert data["new_head"].startswith("sha256:")
480
481 def test_abort_new_head_is_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
482 _init_repo(tmp_path)
483 base = _make_commit(tmp_path)
484 state = RebaseState(
485 original_branch="main", original_head=base, onto=base,
486 remaining=[], completed=[], squash=False,
487 )
488 save_rebase_state(tmp_path, state)
489 result = _invoke(["rebase", "--abort", "--json"], tmp_path)
490 assert result.exit_code == 0, result.output
491 data = _json_from(result.output)
492 assert data["new_head"].startswith("sha256:"), (
493 f"abort new_head not sha256:-prefixed: {data['new_head']!r}"
494 )
495
496 def test_dry_run_commit_ids_are_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
497 _init_repo(tmp_path)
498 base = _make_commit(tmp_path)
499 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
500 _make_commit(tmp_path, parent_id=base)
501 result = _invoke(["rebase", "--dry-run", "--json", "upstream"], tmp_path)
502 assert result.exit_code == 0, result.output
503 data = _json_from(result.output)
504 for entry in data["commits"]:
505 assert entry["commit_id"].startswith("sha256:"), (
506 f"dry_run commit_id not sha256:-prefixed: {entry['commit_id']!r}"
507 )
508
509 def test_status_original_head_is_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
510 _init_repo(tmp_path)
511 base = _make_commit(tmp_path)
512 state = RebaseState(
513 original_branch="main", original_head=base, onto=base,
514 remaining=[], completed=[], squash=False,
515 )
516 save_rebase_state(tmp_path, state)
517 result = _invoke(["rebase", "--status", "--json"], tmp_path)
518 assert result.exit_code == 0
519 data = _json_from(result.output)
520 assert data["original_head"].startswith("sha256:"), (
521 f"status original_head not sha256:-prefixed: {data['original_head']!r}"
522 )
523
524
525 # ---------------------------------------------------------------------------
526 # Full JSON schema — all fields present on each path
527 # ---------------------------------------------------------------------------
528
529
530 class TestJsonSchemaComplete:
531 """Verify all required fields exist in each output path."""
532
533 _RESULT_FIELDS = {
534 "status", "branch", "new_head", "onto", "squash",
535 "replayed", "replayed_commit_ids", "conflicts",
536 "duration_ms", "exit_code",
537 }
538 _STATUS_FIELDS = {
539 "active", "original_branch", "original_head", "onto",
540 "total", "done", "remaining", "squash",
541 "duration_ms", "exit_code",
542 }
543 _DRY_RUN_FIELDS = {
544 "branch", "onto", "commits", "count", "squash",
545 "duration_ms", "exit_code",
546 }
547
548 def test_completed_schema(self, tmp_path: pathlib.Path) -> None:
549 _init_repo(tmp_path)
550 base = _make_commit(tmp_path)
551 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
552 _make_commit(tmp_path, parent_id=base)
553 result = _invoke(["rebase", "--json", "upstream"], tmp_path)
554 assert result.exit_code == 0, result.output
555 data = _json_from(result.output)
556 missing = self._RESULT_FIELDS - set(data)
557 assert not missing, f"completed JSON missing fields: {missing}"
558 assert data["status"] == "completed"
559 assert data["exit_code"] == 0
560 assert data["replayed"] == 1
561 assert len(data["replayed_commit_ids"]) == 1
562
563 def test_aborted_schema(self, tmp_path: pathlib.Path) -> None:
564 _init_repo(tmp_path)
565 base = _make_commit(tmp_path)
566 tip = _make_commit(tmp_path, parent_id=base)
567 state = RebaseState(
568 original_branch="main", original_head=base, onto=base,
569 remaining=[tip], completed=[], squash=False,
570 )
571 save_rebase_state(tmp_path, state)
572 result = _invoke(["rebase", "--abort", "--json"], tmp_path)
573 assert result.exit_code == 0, result.output
574 data = _json_from(result.output)
575 missing = self._RESULT_FIELDS - set(data)
576 assert not missing, f"aborted JSON missing fields: {missing}"
577 assert data["status"] == "aborted"
578 assert data["exit_code"] == 0
579 assert data["new_head"] == base
580 assert data["replayed_commit_ids"] == []
581
582 def test_up_to_date_schema(self, tmp_path: pathlib.Path) -> None:
583 _init_repo(tmp_path)
584 cid = _make_commit(tmp_path)
585 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(cid, encoding="utf-8")
586 result = _invoke(["rebase", "--json", "upstream"], tmp_path)
587 assert result.exit_code == 0, result.output
588 data = _json_from(result.output)
589 missing = self._RESULT_FIELDS - set(data)
590 assert not missing, f"up_to_date JSON missing fields: {missing}"
591 assert data["status"] == "up_to_date"
592 assert data["exit_code"] == 0
593 assert data["replayed"] == 0
594 assert data["replayed_commit_ids"] == []
595
596 def test_dry_run_schema(self, tmp_path: pathlib.Path) -> None:
597 _init_repo(tmp_path)
598 base = _make_commit(tmp_path)
599 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
600 c1 = _make_commit(tmp_path, parent_id=base)
601 result = _invoke(["rebase", "--dry-run", "--json", "upstream"], tmp_path)
602 assert result.exit_code == 0, result.output
603 data = _json_from(result.output)
604 missing = self._DRY_RUN_FIELDS - set(data)
605 assert not missing, f"dry_run JSON missing fields: {missing}"
606 assert data["count"] == 1
607 assert data["commits"][0]["commit_id"] == c1
608 assert data["exit_code"] == 0
609
610 def test_status_schema_inactive(self, tmp_path: pathlib.Path) -> None:
611 _init_repo(tmp_path)
612 _make_commit(tmp_path)
613 result = _invoke(["rebase", "--status", "--json"], tmp_path)
614 assert result.exit_code == 0
615 data = _json_from(result.output)
616 missing = self._STATUS_FIELDS - set(data)
617 assert not missing, f"status JSON missing fields: {missing}"
618 assert data["active"] is False
619 assert data["exit_code"] == 0
620
621 def test_status_schema_active(self, tmp_path: pathlib.Path) -> None:
622 _init_repo(tmp_path)
623 base = _make_commit(tmp_path)
624 state = RebaseState(
625 original_branch="feat/x",
626 original_head=base,
627 onto=base,
628 remaining=[base],
629 completed=[],
630 squash=True,
631 )
632 save_rebase_state(tmp_path, state)
633 result = _invoke(["rebase", "--status", "--json"], tmp_path)
634 assert result.exit_code == 0
635 data = _json_from(result.output)
636 missing = self._STATUS_FIELDS - set(data)
637 assert not missing, f"status (active) JSON missing fields: {missing}"
638 assert data["active"] is True
639 assert data["original_branch"] == "feat/x"
640 assert data["exit_code"] == 0
641
642
643 # ---------------------------------------------------------------------------
644 # Lifecycle integration tests
645 # ---------------------------------------------------------------------------
646
647
648 class TestRebaseLifecycle:
649 """Full lifecycle: init → rebase → result; abort restores HEAD."""
650
651 def test_simple_rebase_completed(self, tmp_path: pathlib.Path) -> None:
652 _init_repo(tmp_path)
653 base = _make_commit(tmp_path)
654 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
655 _make_commit(tmp_path, parent_id=base)
656 result = _invoke(["rebase", "upstream"], tmp_path)
657 assert result.exit_code == 0, result.output
658 assert "complete" in result.output.lower() or "up to date" in result.output.lower()
659
660 def test_abort_restores_head(self, tmp_path: pathlib.Path) -> None:
661 _init_repo(tmp_path)
662 base = _make_commit(tmp_path)
663 tip = _make_commit(tmp_path, parent_id=base)
664 state = RebaseState(
665 original_branch="main", original_head=base, onto=base,
666 remaining=[tip], completed=[], squash=False,
667 )
668 save_rebase_state(tmp_path, state)
669 result = _invoke(["rebase", "--abort"], tmp_path)
670 assert result.exit_code == 0
671 assert "aborted" in result.output.lower()
672 assert load_rebase_state(tmp_path) is None
673 restored = (tmp_path / ".muse" / "refs" / "heads" / "main").read_text(encoding="utf-8").strip()
674 assert restored == base
675
676 def test_abort_text_shows_sha256_short_id(self, tmp_path: pathlib.Path) -> None:
677 """Abort text output must show sha256:<12 hex chars>, not bare hex."""
678 _init_repo(tmp_path)
679 base = _make_commit(tmp_path)
680 state = RebaseState(
681 original_branch="main", original_head=base, onto=base,
682 remaining=[], completed=[], squash=False,
683 )
684 save_rebase_state(tmp_path, state)
685 result = _invoke(["rebase", "--abort"], tmp_path)
686 assert result.exit_code == 0
687 expected_short = long_id(base[7:19])# prefix + 12 hex chars
688 assert expected_short in result.output, (
689 f"Expected {expected_short!r} in abort text output: {result.output!r}"
690 )
691
692 def test_already_up_to_date_text(self, tmp_path: pathlib.Path) -> None:
693 _init_repo(tmp_path)
694 cid = _make_commit(tmp_path)
695 (tmp_path / ".muse" / "refs" / "heads" / "up").write_text(cid, encoding="utf-8")
696 result = _invoke(["rebase", "up"], tmp_path)
697 assert result.exit_code == 0
698 assert "up to date" in result.output.lower()
699
700 def test_dry_run_no_side_effects(self, tmp_path: pathlib.Path) -> None:
701 """--dry-run must not write REBASE_STATE.json or modify branch refs."""
702 _init_repo(tmp_path)
703 base = _make_commit(tmp_path)
704 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
705 c1 = _make_commit(tmp_path, parent_id=base)
706 original_head = (tmp_path / ".muse" / "refs" / "heads" / "main").read_text(encoding="utf-8").strip()
707 result = _invoke(["rebase", "--dry-run", "upstream"], tmp_path)
708 assert result.exit_code == 0
709 assert not (tmp_path / _REBASE_STATE_FILE).exists()
710 new_head = (tmp_path / ".muse" / "refs" / "heads" / "main").read_text(encoding="utf-8").strip()
711 assert new_head == original_head
712 expected_short = long_id(c1[7:19])
713 assert expected_short in result.output
714
715 def test_dry_run_squash_flag(self, tmp_path: pathlib.Path) -> None:
716 _init_repo(tmp_path)
717 base = _make_commit(tmp_path)
718 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
719 _make_commit(tmp_path, parent_id=base)
720 result = _invoke(["rebase", "--dry-run", "--squash", "--json", "upstream"], tmp_path)
721 assert result.exit_code == 0, result.output
722 data = _json_from(result.output)
723 assert data["squash"] is True
724
725 def test_status_text_inactive(self, tmp_path: pathlib.Path) -> None:
726 _init_repo(tmp_path)
727 _make_commit(tmp_path)
728 result = _invoke(["rebase", "--status"], tmp_path)
729 assert result.exit_code == 0
730 assert "No rebase" in result.output
731
732 def test_status_text_active(self, tmp_path: pathlib.Path) -> None:
733 _init_repo(tmp_path)
734 base = _make_commit(tmp_path)
735 state = RebaseState(
736 original_branch="feat/y", original_head=base, onto=base,
737 remaining=[base], completed=[], squash=True,
738 )
739 save_rebase_state(tmp_path, state)
740 result = _invoke(["rebase", "--status"], tmp_path)
741 assert result.exit_code == 0
742 assert "feat/y" in result.output
743
744 def test_completed_clears_state_file(self, tmp_path: pathlib.Path) -> None:
745 """After a clean rebase, REBASE_STATE.json must be removed."""
746 _init_repo(tmp_path)
747 base = _make_commit(tmp_path)
748 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
749 _make_commit(tmp_path, parent_id=base)
750 result = _invoke(["rebase", "upstream"], tmp_path)
751 assert result.exit_code == 0, result.output
752 assert load_rebase_state(tmp_path) is None
753
754 def test_max_commits_cap(self, tmp_path: pathlib.Path) -> None:
755 """--max-commits 2 on a 5-commit chain reports at most 2."""
756 _init_repo(tmp_path)
757 base = _make_commit(tmp_path)
758 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
759 prev = base
760 for _ in range(5):
761 prev = _make_commit(tmp_path, parent_id=prev)
762 result = _invoke(
763 ["rebase", "--dry-run", "--json", "--max-commits", "2", "upstream"], tmp_path
764 )
765 assert result.exit_code == 0, result.output
766 data = _json_from(result.output)
767 assert data["count"] <= 2
768
769
770 # ---------------------------------------------------------------------------
771 # Error paths
772 # ---------------------------------------------------------------------------
773
774
775 class TestErrors:
776 """Error conditions must exit non-zero."""
777
778 def test_no_upstream_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
779 _init_repo(tmp_path)
780 _make_commit(tmp_path)
781 result = _invoke(["rebase"], tmp_path)
782 assert result.exit_code != 0
783
784 def test_unknown_upstream_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
785 _init_repo(tmp_path)
786 _make_commit(tmp_path)
787 result = _invoke(["rebase", "nonexistent-branch-xyz"], tmp_path)
788 assert result.exit_code != 0
789 assert "not found" in result.output.lower()
790
791 def test_abort_no_state_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
792 _init_repo(tmp_path)
793 result = _invoke(["rebase", "--abort"], tmp_path)
794 assert result.exit_code != 0
795
796 def test_continue_no_state_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
797 _init_repo(tmp_path)
798 result = _invoke(["rebase", "--continue"], tmp_path)
799 assert result.exit_code != 0
800
801 def test_rebase_in_progress_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
802 _init_repo(tmp_path)
803 base = _make_commit(tmp_path)
804 state = RebaseState(
805 original_branch="main", original_head=base, onto=base,
806 remaining=[], completed=[], squash=False,
807 )
808 save_rebase_state(tmp_path, state)
809 result = _invoke(["rebase", "main"], tmp_path)
810 assert result.exit_code != 0
811 assert "--continue" in result.output or "--abort" in result.output
812
813
814 # ---------------------------------------------------------------------------
815 # Security — symlink and size guards (from hardening tests)
816 # ---------------------------------------------------------------------------
817
818
819 class TestSecurity:
820 """Symlink and size-cap guards on REBASE_STATE.json."""
821
822 def test_load_rebase_state_symlink_rejected(self, tmp_path: pathlib.Path) -> None:
823 _init_repo(tmp_path)
824 state_path = tmp_path / _REBASE_STATE_FILE
825 target = tmp_path / "sensitive.json"
826 target.write_text(
827 json.dumps({
828 "original_branch": "main",
829 "original_head": "a" * 64,
830 "onto": "b" * 64,
831 "remaining": [],
832 "completed": [],
833 "squash": False,
834 }),
835 encoding="utf-8",
836 )
837 state_path.symlink_to(target)
838 result = load_rebase_state(tmp_path)
839 assert result is None, "Symlinked state file must be rejected"
840
841 def test_save_rebase_state_symlink_rejected(self, tmp_path: pathlib.Path) -> None:
842 _init_repo(tmp_path)
843 state_path = tmp_path / _REBASE_STATE_FILE
844 target = tmp_path / "victim.json"
845 target.write_text("{}", encoding="utf-8")
846 state_path.symlink_to(target)
847 state = RebaseState(
848 original_branch="main", original_head="a" * 64, onto="b" * 64,
849 remaining=[], completed=[], squash=False,
850 )
851 with pytest.raises(OSError, match="symlink"):
852 save_rebase_state(tmp_path, state)
853 assert target.read_text(encoding="utf-8") == "{}"
854
855 def test_clear_rebase_state_symlink_not_deleted(self, tmp_path: pathlib.Path) -> None:
856 _init_repo(tmp_path)
857 state_path = tmp_path / _REBASE_STATE_FILE
858 target = tmp_path / "do_not_delete.json"
859 target.write_text("important", encoding="utf-8")
860 state_path.symlink_to(target)
861 clear_rebase_state(tmp_path)
862 assert target.exists()
863
864 def test_load_rebase_state_size_cap_rejected(self, tmp_path: pathlib.Path) -> None:
865 _init_repo(tmp_path)
866 state_path = tmp_path / _REBASE_STATE_FILE
867 state_path.write_bytes(b"x" * (_MAX_STATE_BYTES + 1))
868 result = load_rebase_state(tmp_path)
869 assert result is None
870
871 def test_load_rebase_state_exactly_at_cap_rejected(self, tmp_path: pathlib.Path) -> None:
872 _init_repo(tmp_path)
873 state_path = tmp_path / _REBASE_STATE_FILE
874 state_path.write_bytes(b"y" * _MAX_STATE_BYTES)
875 result = load_rebase_state(tmp_path)
876 assert result is None # invalid JSON, size check fires first
877
878
879 # ---------------------------------------------------------------------------
880 # Performance
881 # ---------------------------------------------------------------------------
882
883
884 class TestPerformance:
885 """Timing guards — key operations must complete quickly."""
886
887 def test_status_completes_within_200ms(self, tmp_path: pathlib.Path) -> None:
888 _init_repo(tmp_path)
889 _make_commit(tmp_path)
890 t0 = time.monotonic()
891 result = _invoke(["rebase", "--status", "--json"], tmp_path)
892 elapsed = time.monotonic() - t0
893 assert result.exit_code == 0
894 assert elapsed < 0.2, f"--status took {elapsed*1000:.1f}ms (expected <200ms)"
895
896 def test_dry_run_50_commits_completes_within_5s(self, tmp_path: pathlib.Path) -> None:
897 _init_repo(tmp_path)
898 base = _make_commit(tmp_path, content=b"perf-base")
899 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
900 prev = base
901 for i in range(50):
902 prev = _make_commit(tmp_path, parent_id=prev, content=f"p{i}".encode())
903 t0 = time.monotonic()
904 result = _invoke(["rebase", "--dry-run", "--json", "upstream"], tmp_path)
905 elapsed = time.monotonic() - t0
906 assert result.exit_code == 0, result.output
907 data = _json_from(result.output)
908 assert data["count"] == 50
909 assert elapsed < 5.0, f"dry-run 50 commits took {elapsed:.2f}s (expected <5s)"
910
911 def test_duration_ms_is_positive(self, tmp_path: pathlib.Path) -> None:
912 _init_repo(tmp_path)
913 cid = _make_commit(tmp_path)
914 (tmp_path / ".muse" / "refs" / "heads" / "up").write_text(cid, encoding="utf-8")
915 result = _invoke(["rebase", "--json", "up"], tmp_path)
916 data = _json_from(result.output)
917 # duration_ms must be a number (could be 0.0 on very fast systems, but always a float)
918 assert isinstance(data["duration_ms"], (int, float))
919
920
921 # ---------------------------------------------------------------------------
922 # Stress
923 # ---------------------------------------------------------------------------
924
925
926 class TestStress:
927 """Large rebase chains and concurrent operations."""
928
929 def test_collect_20_commits(self, tmp_path: pathlib.Path) -> None:
930 _init_repo(tmp_path)
931 base = _make_commit(tmp_path, content=b"stress-base")
932 prev = base
933 ids = []
934 for i in range(20):
935 prev = _make_commit(tmp_path, parent_id=prev, content=f"s{i}".encode())
936 ids.append(prev)
937 result = collect_commits_to_replay(tmp_path, stop_at=base, tip=prev)
938 assert len(result) == 20
939 assert result[0].commit_id == ids[0]
940 assert result[-1].commit_id == ids[-1]
941
942 def test_50_commit_dry_run_json(self, tmp_path: pathlib.Path) -> None:
943 _init_repo(tmp_path)
944 base = _make_commit(tmp_path, content=b"fifty-base")
945 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(base, encoding="utf-8")
946 prev = base
947 ids = []
948 for i in range(50):
949 prev = _make_commit(tmp_path, parent_id=prev, content=f"t{i}".encode())
950 ids.append(prev)
951 result = _invoke(["rebase", "--dry-run", "--json", "upstream"], tmp_path)
952 assert result.exit_code == 0, result.output
953 data = _json_from(result.output)
954 assert data["count"] == 50
955 assert len(data["commits"]) == 50
956 assert data["commits"][0]["commit_id"] == ids[0]
957 assert data["commits"][-1]["commit_id"] == ids[-1]
958 # All IDs must be sha256:-prefixed
959 for entry in data["commits"]:
960 assert entry["commit_id"].startswith("sha256:")
961
962 def test_concurrent_status_reads(self, tmp_path: pathlib.Path) -> None:
963 """Multiple threads calling get_rebase_progress must not crash."""
964 _init_repo(tmp_path)
965 state = RebaseState(
966 original_branch="main", original_head="a" * 64, onto="b" * 64,
967 remaining=["c" * 64] * 10, completed=["d" * 64] * 5, squash=False,
968 )
969 save_rebase_state(tmp_path, state)
970 errors: list[str] = []
971
972 def _read() -> None:
973 try:
974 p = get_rebase_progress(tmp_path)
975 assert p["active"] is True
976 except Exception as exc:
977 errors.append(str(exc))
978
979 threads = [threading.Thread(target=_read) for _ in range(20)]
980 for t in threads:
981 t.start()
982 for t in threads:
983 t.join()
984 assert not errors, f"Concurrent status failures: {errors}"
985
986 def test_status_1000_element_state(self, tmp_path: pathlib.Path) -> None:
987 """get_rebase_progress is fast even with a 1000-element state."""
988 _init_repo(tmp_path)
989 state = RebaseState(
990 original_branch="main", original_head="a" * 64, onto="b" * 64,
991 remaining=["c" * 64] * 500, completed=["d" * 64] * 500, squash=False,
992 )
993 save_rebase_state(tmp_path, state)
994 p = get_rebase_progress(tmp_path)
995 assert p["total"] == 1000
996 assert p["done"] == 500
997 assert p["remaining"] == 500
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago