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