gabriel / muse public
test_cmd_rebase_hardening.py python
876 lines 28.5 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Hardening tests for ``muse rebase`` and ``muse/core/rebase.py``.
2
3 Covers:
4 - Security: symlink guard on REBASE_STATE.json (load, save, clear)
5 - Security: size cap on REBASE_STATE.json
6 - Error routing: all user-visible errors go to stderr
7 - JSON schema validation: every outcome shape (_RebaseResultJson,
8 _RebaseStatusJson, _RebaseDryRunJson)
9 - --dry-run: preview without side effects
10 - --status: progress reporting for active and inactive rebase
11 - --max-commits: cap enforcement
12 - Integration: full lifecycle — init → rebase → abort / continue
13 - Stress: 50-commit chain, repeated collect_commits_to_replay calls
14 """
15
16 from __future__ import annotations
17
18 import datetime
19 import hashlib
20 import json
21 import pathlib
22 import threading
23 from typing import TypedDict
24
25 import pytest
26
27 from tests.cli_test_helper import CliRunner, InvokeResult
28 from muse.core.object_store import write_object
29 from muse.core.rebase import (
30 RebaseState,
31 RebaseProgress,
32 _MAX_STATE_BYTES,
33 _REBASE_STATE_FILE,
34 clear_rebase_state,
35 collect_commits_to_replay,
36 get_rebase_progress,
37 load_rebase_state,
38 save_rebase_state,
39 )
40 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
41 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
42 from muse.core._types import Manifest
43
44 runner = CliRunner()
45 _REPO_ID = "rebase-harden-test"
46
47 # ---------------------------------------------------------------------------
48 # TypedDicts for JSON schema validation
49 # ---------------------------------------------------------------------------
50
51
52 class _ResultJson(TypedDict):
53 status: str
54 branch: str
55 new_head: str | None
56 onto: str
57 squash: bool
58 replayed: int
59 conflicts: list[str]
60
61
62 class _StatusJson(TypedDict):
63 active: bool
64 original_branch: str
65 original_head: str
66 onto: str
67 total: int
68 done: int
69 remaining: int
70 squash: bool
71
72
73 class _DryRunCommitJson(TypedDict):
74 commit_id: str
75 message: str
76
77
78 class _DryRunJson(TypedDict):
79 branch: str
80 onto: str
81 commits: list[_DryRunCommitJson]
82 count: int
83 squash: bool
84
85
86 # ---------------------------------------------------------------------------
87 # Helpers
88 # ---------------------------------------------------------------------------
89
90
91 def _sha(data: bytes) -> str:
92 return hashlib.sha256(data).hexdigest()
93
94
95 def _init_repo(path: pathlib.Path) -> pathlib.Path:
96 muse = path / ".muse"
97 for d in ("commits", "snapshots", "objects", "refs/heads"):
98 (muse / d).mkdir(parents=True, exist_ok=True)
99 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
100 (muse / "repo.json").write_text(
101 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
102 )
103 return path
104
105
106 _counter = 0
107 _counter_lock = threading.Lock()
108
109
110 def _make_commit(
111 root: pathlib.Path,
112 parent_id: str | None = None,
113 content: bytes = b"data",
114 branch: str = "main",
115 ) -> str:
116 global _counter
117 with _counter_lock:
118 _counter += 1
119 c_val = _counter
120 c = content + str(c_val).encode()
121 obj_id = _sha(c)
122 write_object(root, obj_id, c)
123 manifest = {f"f_{c_val}.txt": obj_id}
124 snap_id = compute_snapshot_id(manifest)
125 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
126 committed_at = datetime.datetime.now(datetime.timezone.utc)
127 parent_ids = [parent_id] if parent_id else []
128 commit_id = compute_commit_id(parent_ids, snap_id, f"commit {c_val}", committed_at.isoformat())
129 write_commit(root, CommitRecord(
130 commit_id=commit_id,
131 repo_id=_REPO_ID,
132 branch=branch,
133 snapshot_id=snap_id,
134 message=f"commit {c_val}",
135 committed_at=committed_at,
136 parent_commit_id=parent_id,
137 ))
138 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id, encoding="utf-8")
139 return commit_id
140
141
142 def _env(repo: pathlib.Path) -> Manifest:
143 return {"MUSE_REPO_ROOT": str(repo)}
144
145
146 def _json_blob(output: str) -> str:
147 """Extract the first JSON object from mixed CLI output."""
148 for line in output.splitlines():
149 line = line.strip()
150 if line.startswith("{") or line.startswith("["):
151 return line
152 return output.strip()
153
154
155 def _parse_result(output: str) -> _ResultJson:
156 raw = json.loads(_json_blob(output))
157 assert isinstance(raw, dict), f"Expected dict, got: {raw!r}"
158 status_val = raw["status"]
159 branch_val = raw["branch"]
160 new_head_val = raw.get("new_head")
161 onto_val = raw["onto"]
162 squash_val = raw["squash"]
163 replayed_val = raw["replayed"]
164 conflicts_val = raw["conflicts"]
165 assert isinstance(status_val, str)
166 assert isinstance(branch_val, str)
167 assert new_head_val is None or isinstance(new_head_val, str)
168 assert isinstance(onto_val, str)
169 assert isinstance(squash_val, bool)
170 assert isinstance(replayed_val, int)
171 assert isinstance(conflicts_val, list)
172 return _ResultJson(
173 status=status_val,
174 branch=branch_val,
175 new_head=new_head_val,
176 onto=onto_val,
177 squash=squash_val,
178 replayed=replayed_val,
179 conflicts=conflicts_val,
180 )
181
182
183 def _parse_status(output: str) -> _StatusJson:
184 raw = json.loads(_json_blob(output))
185 assert isinstance(raw, dict)
186 active_val = raw["active"]
187 ob_val = raw["original_branch"]
188 oh_val = raw["original_head"]
189 onto_val = raw["onto"]
190 total_val = raw["total"]
191 done_val = raw["done"]
192 remaining_val = raw["remaining"]
193 squash_val = raw["squash"]
194 assert isinstance(active_val, bool)
195 assert isinstance(ob_val, str)
196 assert isinstance(oh_val, str)
197 assert isinstance(onto_val, str)
198 assert isinstance(total_val, int)
199 assert isinstance(done_val, int)
200 assert isinstance(remaining_val, int)
201 assert isinstance(squash_val, bool)
202 return _StatusJson(
203 active=active_val,
204 original_branch=ob_val,
205 original_head=oh_val,
206 onto=onto_val,
207 total=total_val,
208 done=done_val,
209 remaining=remaining_val,
210 squash=squash_val,
211 )
212
213
214 def _parse_dry_run(output: str) -> _DryRunJson:
215 raw = json.loads(_json_blob(output))
216 assert isinstance(raw, dict)
217 branch_val = raw["branch"]
218 onto_val = raw["onto"]
219 commits_raw = raw["commits"]
220 count_val = raw["count"]
221 squash_val = raw["squash"]
222 assert isinstance(branch_val, str)
223 assert isinstance(onto_val, str)
224 assert isinstance(commits_raw, list)
225 assert isinstance(count_val, int)
226 assert isinstance(squash_val, bool)
227 commits: list[_DryRunCommitJson] = []
228 for entry in commits_raw:
229 assert isinstance(entry, dict)
230 cid = entry["commit_id"]
231 msg = entry["message"]
232 assert isinstance(cid, str)
233 assert isinstance(msg, str)
234 commits.append(_DryRunCommitJson(commit_id=cid, message=msg))
235 return _DryRunJson(
236 branch=branch_val,
237 onto=onto_val,
238 commits=commits,
239 count=count_val,
240 squash=squash_val,
241 )
242
243
244 def _invoke(args: list[str], repo: pathlib.Path) -> InvokeResult:
245 return runner.invoke(None, args, env=_env(repo))
246
247
248 # ---------------------------------------------------------------------------
249 # Unit: Security — symlink guard on load_rebase_state
250 # ---------------------------------------------------------------------------
251
252
253 def test_load_rebase_state_symlink_is_rejected(tmp_path: pathlib.Path) -> None:
254 """A symlink planted at REBASE_STATE.json must be silently ignored."""
255 _init_repo(tmp_path)
256 state_path = tmp_path / _REBASE_STATE_FILE
257 target = tmp_path / "sensitive.json"
258 target.write_text(
259 json.dumps({
260 "original_branch": "main",
261 "original_head": "a" * 64,
262 "onto": "b" * 64,
263 "remaining": [],
264 "completed": [],
265 "squash": False,
266 }),
267 encoding="utf-8",
268 )
269 state_path.symlink_to(target)
270 result = load_rebase_state(tmp_path)
271 assert result is None, "Symlinked state file should be rejected"
272
273
274 def test_save_rebase_state_symlink_is_rejected(tmp_path: pathlib.Path) -> None:
275 """Writing state to a symlink must raise OSError."""
276 _init_repo(tmp_path)
277 state_path = tmp_path / _REBASE_STATE_FILE
278 target = tmp_path / "victim.json"
279 target.write_text("{}", encoding="utf-8")
280 state_path.symlink_to(target)
281 state = RebaseState(
282 original_branch="main",
283 original_head="a" * 64,
284 onto="b" * 64,
285 remaining=[],
286 completed=[],
287 squash=False,
288 )
289 with pytest.raises(OSError, match="symlink"):
290 save_rebase_state(tmp_path, state)
291 # Victim file must be untouched.
292 assert target.read_text(encoding="utf-8") == "{}"
293
294
295 def test_clear_rebase_state_symlink_is_rejected(tmp_path: pathlib.Path) -> None:
296 """clear_rebase_state must not unlink a symlink."""
297 _init_repo(tmp_path)
298 state_path = tmp_path / _REBASE_STATE_FILE
299 target = tmp_path / "do_not_delete.json"
300 target.write_text("important", encoding="utf-8")
301 state_path.symlink_to(target)
302 clear_rebase_state(tmp_path) # must not raise
303 assert target.exists(), "Target file must not be deleted through symlink"
304 assert state_path.exists(), "Symlink itself should remain"
305
306
307 # ---------------------------------------------------------------------------
308 # Unit: Security — size cap on load_rebase_state
309 # ---------------------------------------------------------------------------
310
311
312 def test_load_rebase_state_size_cap_rejected(tmp_path: pathlib.Path) -> None:
313 """A state file exceeding _MAX_STATE_BYTES must be rejected."""
314 _init_repo(tmp_path)
315 state_path = tmp_path / _REBASE_STATE_FILE
316 # Write more than the cap directly.
317 state_path.write_bytes(b"x" * (_MAX_STATE_BYTES + 1))
318 result = load_rebase_state(tmp_path)
319 assert result is None, "Oversized state file should be rejected"
320
321
322 def test_load_rebase_state_exactly_at_cap_is_rejected(tmp_path: pathlib.Path) -> None:
323 """A state file at exactly _MAX_STATE_BYTES (but invalid JSON) is rejected."""
324 _init_repo(tmp_path)
325 state_path = tmp_path / _REBASE_STATE_FILE
326 state_path.write_bytes(b"y" * _MAX_STATE_BYTES)
327 result = load_rebase_state(tmp_path)
328 # Content is invalid JSON, so also None — but the size check fires first.
329 assert result is None
330
331
332 # ---------------------------------------------------------------------------
333 # Unit: get_rebase_progress
334 # ---------------------------------------------------------------------------
335
336
337 def test_get_rebase_progress_inactive(tmp_path: pathlib.Path) -> None:
338 _init_repo(tmp_path)
339 p = get_rebase_progress(tmp_path)
340 assert isinstance(p, dict)
341 assert p["active"] is False
342 assert p["total"] == 0
343 assert p["done"] == 0
344 assert p["remaining"] == 0
345
346
347 def test_get_rebase_progress_active(tmp_path: pathlib.Path) -> None:
348 _init_repo(tmp_path)
349 state = RebaseState(
350 original_branch="feat/x",
351 original_head="a" * 64,
352 onto="b" * 64,
353 remaining=["c" * 64, "d" * 64],
354 completed=["e" * 64],
355 squash=True,
356 )
357 save_rebase_state(tmp_path, state)
358 p = get_rebase_progress(tmp_path)
359 assert p["active"] is True
360 assert p["original_branch"] == "feat/x"
361 assert p["total"] == 3 # 1 done + 2 remaining
362 assert p["done"] == 1
363 assert p["remaining"] == 2
364 assert p["squash"] is True
365
366
367 # ---------------------------------------------------------------------------
368 # Error routing: errors go to stderr
369 # ---------------------------------------------------------------------------
370
371
372 def test_abort_no_state_error_to_stderr(tmp_path: pathlib.Path) -> None:
373 _init_repo(tmp_path)
374 result = _invoke(["rebase", "--abort"], tmp_path)
375 assert result.exit_code != 0
376 assert "No rebase" in result.output or "No rebase" in (result.stderr or "")
377
378
379 def test_continue_no_state_error_to_stderr(tmp_path: pathlib.Path) -> None:
380 _init_repo(tmp_path)
381 result = _invoke(["rebase", "--continue"], tmp_path)
382 assert result.exit_code != 0
383 assert "Nothing to continue" in result.output or "No rebase" in result.output
384
385
386 def test_rebase_in_progress_error_to_stderr(tmp_path: pathlib.Path) -> None:
387 _init_repo(tmp_path)
388 base = _make_commit(tmp_path)
389 state = RebaseState(
390 original_branch="main",
391 original_head=base,
392 onto=base,
393 remaining=[],
394 completed=[],
395 squash=False,
396 )
397 save_rebase_state(tmp_path, state)
398 result = _invoke(["rebase", "main"], tmp_path)
399 assert result.exit_code != 0
400 # Error should mention --continue or --abort
401 assert "--continue" in result.output or "--abort" in result.output
402
403
404 def test_no_upstream_error_to_stderr(tmp_path: pathlib.Path) -> None:
405 _init_repo(tmp_path)
406 _make_commit(tmp_path)
407 result = _invoke(["rebase"], tmp_path)
408 assert result.exit_code != 0
409
410
411 def test_unknown_upstream_error(tmp_path: pathlib.Path) -> None:
412 _init_repo(tmp_path)
413 _make_commit(tmp_path)
414 result = _invoke(["rebase", "nonexistent-branch"], tmp_path)
415 assert result.exit_code != 0
416 assert "not found" in result.output.lower()
417
418
419 # ---------------------------------------------------------------------------
420 # JSON schema: --status
421 # ---------------------------------------------------------------------------
422
423
424 def test_status_json_inactive(tmp_path: pathlib.Path) -> None:
425 _init_repo(tmp_path)
426 result = _invoke(["rebase", "--status", "--json"], tmp_path)
427 assert result.exit_code == 0
428 parsed = _parse_status(result.output)
429 assert parsed["active"] is False
430 assert parsed["total"] == 0
431 assert parsed["done"] == 0
432 assert parsed["remaining"] == 0
433
434
435 def test_status_json_active(tmp_path: pathlib.Path) -> None:
436 _init_repo(tmp_path)
437 state = RebaseState(
438 original_branch="feat/x",
439 original_head="a" * 64,
440 onto="b" * 64,
441 remaining=["c" * 64, "d" * 64, "f" * 64],
442 completed=["e" * 64, "g" * 64],
443 squash=False,
444 )
445 save_rebase_state(tmp_path, state)
446 result = _invoke(["rebase", "--status", "--json"], tmp_path)
447 assert result.exit_code == 0
448 parsed = _parse_status(result.output)
449 assert parsed["active"] is True
450 assert parsed["original_branch"] == "feat/x"
451 assert parsed["total"] == 5
452 assert parsed["done"] == 2
453 assert parsed["remaining"] == 3
454 assert parsed["squash"] is False
455
456
457 def test_status_text_inactive(tmp_path: pathlib.Path) -> None:
458 _init_repo(tmp_path)
459 result = _invoke(["rebase", "--status"], tmp_path)
460 assert result.exit_code == 0
461 assert "No rebase" in result.output
462
463
464 def test_status_text_active(tmp_path: pathlib.Path) -> None:
465 _init_repo(tmp_path)
466 state = RebaseState(
467 original_branch="feat/y",
468 original_head="a" * 64,
469 onto="b" * 64,
470 remaining=["c" * 64],
471 completed=["d" * 64],
472 squash=True,
473 )
474 save_rebase_state(tmp_path, state)
475 result = _invoke(["rebase", "--status"], tmp_path)
476 assert result.exit_code == 0
477 assert "feat/y" in result.output
478 assert "1/2" in result.output
479
480
481 # ---------------------------------------------------------------------------
482 # JSON schema: --abort
483 # ---------------------------------------------------------------------------
484
485
486 def test_abort_json_schema(tmp_path: pathlib.Path) -> None:
487 _init_repo(tmp_path)
488 base = _make_commit(tmp_path)
489 tip = _make_commit(tmp_path, parent_id=base)
490 state = RebaseState(
491 original_branch="main",
492 original_head=base,
493 onto=base,
494 remaining=[tip],
495 completed=[],
496 squash=False,
497 )
498 save_rebase_state(tmp_path, state)
499 result = _invoke(["rebase", "--abort", "--json"], tmp_path)
500 assert result.exit_code == 0, result.output
501 parsed = _parse_result(result.output)
502 assert parsed["status"] == "aborted"
503 assert parsed["branch"] == "main"
504 assert parsed["new_head"] == base
505 assert parsed["squash"] is False
506 assert parsed["conflicts"] == []
507
508
509 # ---------------------------------------------------------------------------
510 # JSON schema: already up to date
511 # ---------------------------------------------------------------------------
512
513
514 def test_already_up_to_date_json(tmp_path: pathlib.Path) -> None:
515 _init_repo(tmp_path)
516 cid = _make_commit(tmp_path)
517 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(
518 cid, encoding="utf-8"
519 )
520 result = _invoke(["rebase", "--json", "upstream"], tmp_path)
521 assert result.exit_code == 0, result.output
522 parsed = _parse_result(result.output)
523 assert parsed["status"] == "up_to_date"
524 assert parsed["replayed"] == 0
525 assert parsed["conflicts"] == []
526
527
528 # ---------------------------------------------------------------------------
529 # JSON schema: --dry-run
530 # ---------------------------------------------------------------------------
531
532
533 def test_dry_run_json_schema(tmp_path: pathlib.Path) -> None:
534 _init_repo(tmp_path)
535 base = _make_commit(tmp_path)
536 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(
537 base, encoding="utf-8"
538 )
539 c1 = _make_commit(tmp_path, parent_id=base)
540 c2 = _make_commit(tmp_path, parent_id=c1)
541 result = _invoke(["rebase", "--dry-run", "--json", "upstream"], tmp_path)
542 assert result.exit_code == 0, result.output
543 parsed = _parse_dry_run(result.output)
544 assert parsed["branch"] == "main"
545 assert parsed["onto"] == base
546 assert parsed["count"] == 2
547 assert parsed["squash"] is False
548 assert len(parsed["commits"]) == 2
549 assert parsed["commits"][0]["commit_id"] == c1
550 assert parsed["commits"][1]["commit_id"] == c2
551
552
553 def test_dry_run_no_side_effects(tmp_path: pathlib.Path) -> None:
554 """--dry-run must not write REBASE_STATE.json or modify branch refs."""
555 _init_repo(tmp_path)
556 base = _make_commit(tmp_path)
557 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(
558 base, encoding="utf-8"
559 )
560 c1 = _make_commit(tmp_path, parent_id=base)
561 original_head = (tmp_path / ".muse" / "refs" / "heads" / "main").read_text(
562 encoding="utf-8"
563 ).strip()
564 result = _invoke(["rebase", "--dry-run", "upstream"], tmp_path)
565 assert result.exit_code == 0
566 # State file must not exist after a dry-run.
567 assert not (tmp_path / _REBASE_STATE_FILE).exists()
568 # Branch ref must not change.
569 new_head = (tmp_path / ".muse" / "refs" / "heads" / "main").read_text(
570 encoding="utf-8"
571 ).strip()
572 assert new_head == original_head
573 # Output should describe the plan.
574 assert c1[:12] in result.output
575
576
577 def test_dry_run_text_output(tmp_path: pathlib.Path) -> None:
578 _init_repo(tmp_path)
579 base = _make_commit(tmp_path)
580 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(
581 base, encoding="utf-8"
582 )
583 c1 = _make_commit(tmp_path, parent_id=base)
584 result = _invoke(["rebase", "--dry-run", "upstream"], tmp_path)
585 assert result.exit_code == 0
586 assert "Would rebase" in result.output
587 assert c1[:12] in result.output
588
589
590 # ---------------------------------------------------------------------------
591 # JSON schema: completed rebase (normal)
592 # ---------------------------------------------------------------------------
593
594
595 def test_completed_rebase_json_schema(tmp_path: pathlib.Path) -> None:
596 """muse rebase --json upstream emits a completed result on success."""
597 _init_repo(tmp_path)
598 base = _make_commit(tmp_path)
599 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(
600 base, encoding="utf-8"
601 )
602 c1 = _make_commit(tmp_path, parent_id=base)
603 result = _invoke(["rebase", "--json", "upstream"], tmp_path)
604 assert result.exit_code == 0, result.output
605 parsed = _parse_result(result.output)
606 assert parsed["status"] == "completed"
607 assert parsed["branch"] == "main"
608 assert parsed["squash"] is False
609 assert parsed["replayed"] == 1
610 assert parsed["conflicts"] == []
611 assert isinstance(parsed["new_head"], str) and len(parsed["new_head"]) == 64
612
613
614 # ---------------------------------------------------------------------------
615 # --max-commits: cap enforcement
616 # ---------------------------------------------------------------------------
617
618
619 def test_max_commits_cap(tmp_path: pathlib.Path) -> None:
620 """--max-commits 2 on a 5-commit chain should only replay 2 commits."""
621 _init_repo(tmp_path)
622 base = _make_commit(tmp_path)
623 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(
624 base, encoding="utf-8"
625 )
626 prev = base
627 for _ in range(5):
628 prev = _make_commit(tmp_path, parent_id=prev)
629 result = _invoke(
630 ["rebase", "--dry-run", "--json", "--max-commits", "2", "upstream"], tmp_path
631 )
632 assert result.exit_code == 0, result.output
633 parsed = _parse_dry_run(result.output)
634 # collect_commits_to_replay caps at 2.
635 assert parsed["count"] <= 2
636
637
638 # ---------------------------------------------------------------------------
639 # collect_commits_to_replay: max_commits parameter
640 # ---------------------------------------------------------------------------
641
642
643 def test_collect_commits_max_cap(tmp_path: pathlib.Path) -> None:
644 _init_repo(tmp_path)
645 base = _make_commit(tmp_path)
646 prev = base
647 for i in range(10):
648 prev = _make_commit(tmp_path, parent_id=prev, content=f"cap{i}".encode())
649 result = collect_commits_to_replay(tmp_path, stop_at=base, tip=prev, max_commits=5)
650 assert len(result) <= 5
651
652
653 # ---------------------------------------------------------------------------
654 # Integration: abort clears state and restores HEAD
655 # ---------------------------------------------------------------------------
656
657
658 def test_full_abort_lifecycle(tmp_path: pathlib.Path) -> None:
659 _init_repo(tmp_path)
660 base = _make_commit(tmp_path)
661 tip = _make_commit(tmp_path, parent_id=base)
662 original_head = (tmp_path / ".muse" / "refs" / "heads" / "main").read_text(
663 encoding="utf-8"
664 ).strip()
665 state = RebaseState(
666 original_branch="main",
667 original_head=base,
668 onto=base,
669 remaining=[tip],
670 completed=[],
671 squash=False,
672 )
673 save_rebase_state(tmp_path, state)
674 result = _invoke(["rebase", "--abort"], tmp_path)
675 assert result.exit_code == 0
676 assert "aborted" in result.output.lower()
677 # State cleared.
678 assert load_rebase_state(tmp_path) is None
679 # HEAD restored to original_head (base), not tip.
680 restored = (tmp_path / ".muse" / "refs" / "heads" / "main").read_text(
681 encoding="utf-8"
682 ).strip()
683 assert restored == base
684
685
686 # ---------------------------------------------------------------------------
687 # Integration: --dry-run squash flag propagated
688 # ---------------------------------------------------------------------------
689
690
691 def test_dry_run_squash_flag(tmp_path: pathlib.Path) -> None:
692 _init_repo(tmp_path)
693 base = _make_commit(tmp_path)
694 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(
695 base, encoding="utf-8"
696 )
697 _make_commit(tmp_path, parent_id=base)
698 result = _invoke(
699 ["rebase", "--dry-run", "--squash", "--json", "upstream"], tmp_path
700 )
701 assert result.exit_code == 0, result.output
702 parsed = _parse_dry_run(result.output)
703 assert parsed["squash"] is True
704
705
706 # ---------------------------------------------------------------------------
707 # Integration: already-up-to-date text output
708 # ---------------------------------------------------------------------------
709
710
711 def test_already_up_to_date_text(tmp_path: pathlib.Path) -> None:
712 _init_repo(tmp_path)
713 cid = _make_commit(tmp_path)
714 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(
715 cid, encoding="utf-8"
716 )
717 result = _invoke(["rebase", "upstream"], tmp_path)
718 assert result.exit_code == 0
719 assert "up to date" in result.output.lower()
720
721
722 # ---------------------------------------------------------------------------
723 # Integration: --status reflects paused rebase
724 # ---------------------------------------------------------------------------
725
726
727 def test_status_after_state_saved(tmp_path: pathlib.Path) -> None:
728 _init_repo(tmp_path)
729 state = RebaseState(
730 original_branch="feat/z",
731 original_head="a" * 64,
732 onto="b" * 64,
733 remaining=["c" * 64, "d" * 64],
734 completed=["e" * 64],
735 squash=False,
736 )
737 save_rebase_state(tmp_path, state)
738 result = _invoke(["rebase", "--status", "--json"], tmp_path)
739 assert result.exit_code == 0
740 parsed = _parse_status(result.output)
741 assert parsed["active"] is True
742 assert parsed["remaining"] == 2
743 assert parsed["done"] == 1
744
745
746 # ---------------------------------------------------------------------------
747 # E2E: human-readable text for all outcomes
748 # ---------------------------------------------------------------------------
749
750
751 def test_help_output(tmp_path: pathlib.Path) -> None:
752 result = runner.invoke(None, ["rebase", "--help"])
753 assert result.exit_code == 0
754 assert "--abort" in result.output
755 assert "--continue" in result.output
756 assert "--dry-run" in result.output
757 assert "--status" in result.output
758 assert "--max-commits" in result.output
759 assert "--json" in result.output
760 assert "--squash" in result.output
761
762
763 def test_abort_text_output(tmp_path: pathlib.Path) -> None:
764 _init_repo(tmp_path)
765 base = _make_commit(tmp_path)
766 state = RebaseState(
767 original_branch="main",
768 original_head=base,
769 onto=base,
770 remaining=[],
771 completed=[],
772 squash=False,
773 )
774 save_rebase_state(tmp_path, state)
775 result = _invoke(["rebase", "--abort"], tmp_path)
776 assert result.exit_code == 0
777 assert "aborted" in result.output.lower()
778 assert base[:12] in result.output
779
780
781 def test_up_to_date_text(tmp_path: pathlib.Path) -> None:
782 _init_repo(tmp_path)
783 cid = _make_commit(tmp_path)
784 (tmp_path / ".muse" / "refs" / "heads" / "up").write_text(cid, encoding="utf-8")
785 result = _invoke(["rebase", "up"], tmp_path)
786 assert result.exit_code == 0
787 assert "up to date" in result.output.lower()
788
789
790 # ---------------------------------------------------------------------------
791 # Stress: 50-commit chain dry-run
792 # ---------------------------------------------------------------------------
793
794
795 def test_stress_50_commit_dry_run(tmp_path: pathlib.Path) -> None:
796 _init_repo(tmp_path)
797 base = _make_commit(tmp_path, content=b"stress-base")
798 (tmp_path / ".muse" / "refs" / "heads" / "upstream").write_text(
799 base, encoding="utf-8"
800 )
801 prev = base
802 commit_ids: list[str] = []
803 for i in range(50):
804 prev = _make_commit(tmp_path, parent_id=prev, content=f"s{i}".encode())
805 commit_ids.append(prev)
806
807 result = _invoke(["rebase", "--dry-run", "--json", "upstream"], tmp_path)
808 assert result.exit_code == 0, result.output
809 parsed = _parse_dry_run(result.output)
810 assert parsed["count"] == 50
811 assert len(parsed["commits"]) == 50
812 # Oldest first.
813 assert parsed["commits"][0]["commit_id"] == commit_ids[0]
814 assert parsed["commits"][-1]["commit_id"] == commit_ids[-1]
815
816
817 def test_stress_collect_50_commits(tmp_path: pathlib.Path) -> None:
818 _init_repo(tmp_path)
819 base = _make_commit(tmp_path, content=b"stress-base-2")
820 prev = base
821 ids: list[str] = []
822 for i in range(50):
823 prev = _make_commit(tmp_path, parent_id=prev, content=f"t{i}".encode())
824 ids.append(prev)
825 result = collect_commits_to_replay(tmp_path, stop_at=base, tip=prev)
826 assert len(result) == 50
827 assert result[0].commit_id == ids[0]
828 assert result[-1].commit_id == ids[-1]
829
830
831 def test_stress_status_many_commits(tmp_path: pathlib.Path) -> None:
832 """get_rebase_progress is fast even with a 1000-element state."""
833 _init_repo(tmp_path)
834 state = RebaseState(
835 original_branch="main",
836 original_head="a" * 64,
837 onto="b" * 64,
838 remaining=["c" * 64] * 500,
839 completed=["d" * 64] * 500,
840 squash=False,
841 )
842 save_rebase_state(tmp_path, state)
843 p = get_rebase_progress(tmp_path)
844 assert p["total"] == 1000
845 assert p["done"] == 500
846 assert p["remaining"] == 500
847
848
849 def test_stress_concurrent_status_reads(tmp_path: pathlib.Path) -> None:
850 """Multiple threads calling get_rebase_progress concurrently must not crash."""
851 _init_repo(tmp_path)
852 state = RebaseState(
853 original_branch="main",
854 original_head="a" * 64,
855 onto="b" * 64,
856 remaining=["c" * 64] * 10,
857 completed=["d" * 64] * 5,
858 squash=False,
859 )
860 save_rebase_state(tmp_path, state)
861 errors: list[str] = []
862
863 def _read() -> None:
864 try:
865 p = get_rebase_progress(tmp_path)
866 assert p["active"] is True
867 except Exception as exc: # noqa: BLE001
868 errors.append(str(exc))
869
870 threads = [threading.Thread(target=_read) for _ in range(20)]
871 for t in threads:
872 t.start()
873 for t in threads:
874 t.join()
875
876 assert not errors, f"Concurrent status failures: {errors}"
File History 3 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago