gabriel / muse public
test_cmd_merge_hardening.py python
638 lines 28.4 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 122 days ago
1 """Hardening tests for ``muse merge`` — security, schema, error routing.
2
3 These tests cover the 9 issues fixed in the security/correctness/agent-UX
4 audit. They are intentionally distinct from the existing test_cmd_merge.py
5 and test_cmd_merge_dry_run.py suites, which cover the core merge algorithm.
6
7 Coverage tiers
8 --------------
9 Unit — parser flags, dead-code removal, _use_color, _semver_from_op_log.
10 Integration — error routing to stderr, JSON schema stability across all statuses.
11 End-to-end — full CLI: security, branch-name sanitization, abort, strategy JSON.
12 Security — ANSI injection in branch names, commit messages, conflict paths.
13 Stress — large merges, concurrent repos, abort+re-merge cycles.
14 """
15
16 from __future__ import annotations
17
18 import json
19 import os
20 import pathlib
21 import subprocess
22 import threading
23 import time
24 from typing import TYPE_CHECKING
25
26 import pytest
27
28 from tests.cli_test_helper import CliRunner, InvokeResult
29 from muse.core.store import get_head_commit_id, read_commit, read_current_branch
30 from muse.core.paths import merge_state_path
31
32 if TYPE_CHECKING:
33 import argparse
34
35 runner = CliRunner()
36
37 # ──────────────────────────────────────────────────────────────────────────────
38 # Helpers
39 # ──────────────────────────────────────────────────────────────────────────────
40
41 JSON_REQUIRED_KEYS = {
42 "status", "commit_id", "branch", "current_branch",
43 "base_commit_id", "conflicts", "files_changed", "semver_impact",
44 "strategy", "dry_run",
45 }
46
47
48 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
49 saved = os.getcwd()
50 try:
51 os.chdir(repo)
52 return runner.invoke(None, args)
53 finally:
54 os.chdir(saved)
55
56
57 def _merge(repo: pathlib.Path, *extra: str) -> InvokeResult:
58 return _invoke(repo, ["merge", *extra])
59
60
61 def _commit(repo: pathlib.Path, *extra: str) -> InvokeResult:
62 return _invoke(repo, ["commit", *extra])
63
64
65 @pytest.fixture()
66 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
67 """Initialised repo with one commit on ``main``."""
68 saved = os.getcwd()
69 try:
70 os.chdir(tmp_path)
71 runner.invoke(None, ["init"])
72 finally:
73 os.chdir(saved)
74 (tmp_path / "a.py").write_text("x = 1\n")
75 _commit(tmp_path, "-m", "initial")
76 return tmp_path
77
78
79 @pytest.fixture()
80 def ff_repo(repo: pathlib.Path) -> pathlib.Path:
81 """Repo where ``feat`` is strictly ahead of ``main`` → fast-forward."""
82 _invoke(repo, ["branch", "feat"])
83 _invoke(repo, ["checkout", "feat"])
84 (repo / "b.py").write_text("y = 2\n")
85 _commit(repo, "-m", "feat add b")
86 _invoke(repo, ["checkout", "main"])
87 return repo
88
89
90 @pytest.fixture()
91 def three_way_repo(repo: pathlib.Path) -> pathlib.Path:
92 """Repo requiring a clean three-way merge (both sides diverged)."""
93 _invoke(repo, ["branch", "feat"])
94 _invoke(repo, ["checkout", "feat"])
95 (repo / "b.py").write_text("y = 2\n")
96 _commit(repo, "-m", "feat add b")
97 _invoke(repo, ["checkout", "main"])
98 (repo / "c.py").write_text("z = 3\n")
99 _commit(repo, "-m", "main add c")
100 return repo
101
102
103 @pytest.fixture()
104 def conflict_repo(repo: pathlib.Path) -> pathlib.Path:
105 """Repo where both sides modified the same file — conflict."""
106 _invoke(repo, ["branch", "feat"])
107 _invoke(repo, ["checkout", "feat"])
108 (repo / "a.py").write_text("x = 999\n")
109 _commit(repo, "-m", "feat modify a")
110 _invoke(repo, ["checkout", "main"])
111 (repo / "a.py").write_text("x = 42\n")
112 _commit(repo, "-m", "main modify a")
113 return repo
114
115
116 # ──────────────────────────────────────────────────────────────────────────────
117 # Unit — parser flags
118 # ──────────────────────────────────────────────────────────────────────────────
119
120
121 class TestRegisterFlags:
122 def _parse(self, *args: str) -> "argparse.Namespace":
123 import argparse
124
125 from muse.cli.commands.merge import register
126
127 p = argparse.ArgumentParser()
128 sub = p.add_subparsers()
129 register(sub)
130 return p.parse_args(["merge", *args])
131
132 def test_default_json_is_false(self) -> None:
133 ns = self._parse("feat")
134 assert ns.json_out is False
135
136 def test_json_flag_sets_json_out(self) -> None:
137 ns = self._parse("feat", "--json")
138 assert ns.json_out is True
139
140 def test_dry_run_default_false(self) -> None:
141 ns = self._parse("feat")
142 assert ns.dry_run is False
143
144 def test_dry_run_flag(self) -> None:
145 ns = self._parse("feat", "--dry-run")
146 assert ns.dry_run is True
147
148 def test_strategy_default_none(self) -> None:
149 ns = self._parse("feat")
150 assert ns.strategy is None
151
152 def test_strategy_ours(self) -> None:
153 ns = self._parse("feat", "--strategy", "ours")
154 assert ns.strategy == "ours"
155
156 def test_strategy_theirs(self) -> None:
157 ns = self._parse("feat", "--strategy", "theirs")
158 assert ns.strategy == "theirs"
159
160 def test_no_ff_default_false(self) -> None:
161 ns = self._parse("feat")
162 assert ns.no_ff is False
163
164 def test_abort_default_false(self) -> None:
165 ns = self._parse()
166 assert ns.abort is False
167
168 def test_harmony_autoupdate_default_true(self) -> None:
169 ns = self._parse("feat")
170 assert ns.harmony_autoupdate is True
171
172 def test_no_harmony_autoupdate(self) -> None:
173 ns = self._parse("feat", "--no-harmony-autoupdate")
174 assert ns.harmony_autoupdate is False
175
176
177 # ──────────────────────────────────────────────────────────────────────────────
178 # Unit — dead-code removal
179 # ──────────────────────────────────────────────────────────────────────────────
180
181
182 class TestDeadCodeRemoved:
183 def test_read_branch_wrapper_removed(self) -> None:
184 import muse.cli.commands.merge as m
185
186 assert not hasattr(m, "_read_branch"), (
187 "_read_branch was a dead one-liner wrapper and must be deleted"
188 )
189
190 def test_restore_from_manifest_wrapper_removed(self) -> None:
191 import muse.cli.commands.merge as m
192
193 assert not hasattr(m, "_restore_from_manifest"), (
194 "_restore_from_manifest was a dead one-liner wrapper and must be deleted"
195 )
196
197
198 # ──────────────────────────────────────────────────────────────────────────────
199 # Unit — _use_color
200 # ──────────────────────────────────────────────────────────────────────────────
201
202
203 class TestUseColor:
204 def test_no_color_env_disables_color(self, monkeypatch: pytest.MonkeyPatch) -> None:
205 from muse.core.terminal import use_color
206
207 monkeypatch.setenv("NO_COLOR", "1")
208 assert use_color() is False
209
210 def test_term_dumb_disables_color(self, monkeypatch: pytest.MonkeyPatch) -> None:
211 from muse.core.terminal import use_color
212
213 monkeypatch.setenv("TERM", "dumb")
214 assert use_color() is False
215
216 def test_c_helper_respects_use_color(self, monkeypatch: pytest.MonkeyPatch) -> None:
217 from muse.cli.commands.merge import _c, _GREEN
218
219 monkeypatch.setenv("NO_COLOR", "1")
220 result = _c("hello", _GREEN)
221 assert "\x1b[" not in result
222 assert result == "hello"
223
224
225 # ──────────────────────────────────────────────────────────────────────────────
226 # Unit — _semver_from_op_log
227 # ──────────────────────────────────────────────────────────────────────────────
228
229
230 class TestSemverFromOpLog:
231 """Verify _semver_from_op_log with an empty list (the only type-safe call site).
232 Non-empty behaviour is exercised via dry-run integration tests below,
233 which receive semver_impact in the JSON output after a real plugin diff."""
234
235 def test_empty_returns_empty(self) -> None:
236 from muse.cli.commands.merge import _semver_from_op_log
237
238 assert _semver_from_op_log([]) == ""
239
240 def test_semver_impact_present_in_dry_run_json(
241 self, three_way_repo: pathlib.Path
242 ) -> None:
243 """semver_impact is always a str in the dry-run JSON schema."""
244 result = _merge(three_way_repo, "feat", "--dry-run", "--json")
245 data = json.loads(result.output)
246 assert "semver_impact" in data
247 assert isinstance(data["semver_impact"], str)
248
249 def test_semver_impact_present_in_live_merge_json(
250 self, three_way_repo: pathlib.Path
251 ) -> None:
252 result = _merge(three_way_repo, "feat", "--json")
253 assert result.exit_code == 0
254 data = json.loads(result.output)
255 assert isinstance(data["semver_impact"], str)
256
257
258 # ──────────────────────────────────────────────────────────────────────────────
259 # Integration — error routing to stderr
260 # ──────────────────────────────────────────────────────────────────────────────
261
262
263 class TestErrorRouting:
264 def test_merge_itself_error_to_stderr(self, repo: pathlib.Path) -> None:
265 result = _merge(repo, "main")
266 assert result.exit_code == 1
267 assert "Cannot merge" in (result.stderr or "")
268 assert "Cannot merge" not in result.output.replace(result.stderr or "", "")
269
270 def test_no_branch_arg_error_to_stderr(self, repo: pathlib.Path) -> None:
271 result = _merge(repo)
272 assert result.exit_code == 1
273 assert "Usage" in (result.stderr or "")
274
275 def test_nonexistent_branch_error_to_stderr(self, repo: pathlib.Path) -> None:
276 result = _merge(repo, "ghost-branch")
277 assert result.exit_code == 1
278 assert "no commits" in (result.stderr or "").lower()
279
280 def test_unrecognized_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
281 result = _merge(repo, "main", "--no-such-flag")
282 assert result.exit_code != 0
283
284 def test_conflict_error_to_stderr(self, conflict_repo: pathlib.Path) -> None:
285 result = _merge(conflict_repo, "feat")
286 assert result.exit_code == 1
287 assert "conflict" in (result.stderr or "").lower()
288
289 def test_abort_no_merge_error_to_stderr(self, repo: pathlib.Path) -> None:
290 result = _merge(repo, "--abort")
291 assert result.exit_code == 1
292 assert "No merge in progress" in (result.stderr or "")
293
294
295 # ──────────────────────────────────────────────────────────────────────────────
296 # Integration — JSON schema stability
297 # ──────────────────────────────────────────────────────────────────────────────
298
299
300 class TestJsonSchema:
301 def test_up_to_date_has_all_keys(self, ff_repo: pathlib.Path) -> None:
302 """First merge puts us at up-to-date; merge again to get up_to_date status."""
303 _merge(ff_repo, "feat")
304 result = _merge(ff_repo, "feat", "--json")
305 data = json.loads(result.output)
306 assert data["status"] == "up_to_date"
307 missing = JSON_REQUIRED_KEYS - set(data)
308 assert not missing, f"Missing keys in up_to_date JSON: {missing}"
309
310 def test_fast_forward_has_all_keys(self, ff_repo: pathlib.Path) -> None:
311 result = _merge(ff_repo, "feat", "--json")
312 data = json.loads(result.output)
313 assert data["status"] == "fast_forward"
314 missing = JSON_REQUIRED_KEYS - set(data)
315 assert not missing, f"Missing keys in fast_forward JSON: {missing}"
316
317 def test_three_way_merged_has_all_keys(self, three_way_repo: pathlib.Path) -> None:
318 result = _merge(three_way_repo, "feat", "--json")
319 assert result.exit_code == 0
320 data = json.loads(result.output)
321 assert data["status"] == "merged"
322 missing = JSON_REQUIRED_KEYS - set(data)
323 assert not missing, f"Missing keys in three-way merged JSON: {missing}"
324
325 def test_conflict_has_all_keys(self, conflict_repo: pathlib.Path) -> None:
326 result = _merge(conflict_repo, "feat", "--json")
327 assert result.exit_code == 1
328 data = json.loads(result.output)
329 assert data["status"] == "conflict"
330 # conflict status uses same schema (minus commit_id which is null)
331 core_keys = JSON_REQUIRED_KEYS - {"symbol_conflicts"}
332 missing = core_keys - set(data)
333 assert not missing, f"Missing keys in conflict JSON: {missing}"
334
335 def test_dry_run_merged_has_all_keys(self, three_way_repo: pathlib.Path) -> None:
336 result = _merge(three_way_repo, "feat", "--dry-run", "--json")
337 assert result.exit_code == 0
338 data = json.loads(result.output)
339 assert data["dry_run"] is True
340 missing = JSON_REQUIRED_KEYS - set(data)
341 assert not missing, f"Missing keys in dry-run merged JSON: {missing}"
342
343 def test_strategy_ours_has_all_keys(self, conflict_repo: pathlib.Path) -> None:
344 result = _merge(conflict_repo, "feat", "--strategy", "ours", "--json")
345 assert result.exit_code == 0
346 data = json.loads(result.output)
347 assert data["strategy"] == "ours"
348 missing = JSON_REQUIRED_KEYS - set(data)
349 assert not missing, f"Missing keys in strategy=ours JSON: {missing}"
350
351 def test_strategy_theirs_has_all_keys(self, conflict_repo: pathlib.Path) -> None:
352 result = _merge(conflict_repo, "feat", "--strategy", "theirs", "--json")
353 assert result.exit_code == 0
354 data = json.loads(result.output)
355 assert data["strategy"] == "theirs"
356 missing = JSON_REQUIRED_KEYS - set(data)
357 assert not missing, f"Missing keys in strategy=theirs JSON: {missing}"
358
359 def test_fast_forward_has_base_commit_id(self, ff_repo: pathlib.Path) -> None:
360 result = _merge(ff_repo, "feat", "--json")
361 data = json.loads(result.output)
362 assert "base_commit_id" in data
363 assert data["base_commit_id"] is not None # FF always has a base
364
365 def test_three_way_has_files_changed(self, three_way_repo: pathlib.Path) -> None:
366 result = _merge(three_way_repo, "feat", "--json")
367 assert result.exit_code == 0
368 data = json.loads(result.output)
369 fc = data["files_changed"]
370 assert "added" in fc and "modified" in fc and "deleted" in fc
371
372 def test_three_way_has_semver_impact(self, three_way_repo: pathlib.Path) -> None:
373 result = _merge(three_way_repo, "feat", "--json")
374 assert result.exit_code == 0
375 data = json.loads(result.output)
376 assert "semver_impact" in data
377 assert isinstance(data["semver_impact"], str)
378
379 def test_three_way_has_strategy_null(self, three_way_repo: pathlib.Path) -> None:
380 result = _merge(three_way_repo, "feat", "--json")
381 assert result.exit_code == 0
382 data = json.loads(result.output)
383 assert data["strategy"] is None
384
385 def test_three_way_has_dry_run_false(self, three_way_repo: pathlib.Path) -> None:
386 result = _merge(three_way_repo, "feat", "--json")
387 assert result.exit_code == 0
388 data = json.loads(result.output)
389 assert data["dry_run"] is False
390
391 def test_dry_run_commit_id_is_null(self, three_way_repo: pathlib.Path) -> None:
392 result = _merge(three_way_repo, "feat", "--dry-run", "--json")
393 data = json.loads(result.output)
394 assert data["commit_id"] is None
395
396 def test_live_merge_commit_id_is_sha(self, three_way_repo: pathlib.Path) -> None:
397 result = _merge(three_way_repo, "feat", "--json")
398 data = json.loads(result.output)
399 assert data["commit_id"] is not None
400 assert data["commit_id"].startswith("sha256:") # canonical OID
401
402 def test_files_changed_correct_for_ff(self, ff_repo: pathlib.Path) -> None:
403 result = _merge(ff_repo, "feat", "--json")
404 data = json.loads(result.output)
405 fc = data["files_changed"]
406 assert fc["added"] == 1 # b.py added on feat
407 assert fc["modified"] == 0
408 assert fc["deleted"] == 0
409
410
411 # ──────────────────────────────────────────────────────────────────────────────
412 # Integration — abort
413 # ──────────────────────────────────────────────────────────────────────────────
414
415
416 class TestAbort:
417 def test_abort_no_merge_exits_1(self, repo: pathlib.Path) -> None:
418 result = _merge(repo, "--abort")
419 assert result.exit_code == 1
420
421 def test_abort_no_merge_json(self, repo: pathlib.Path) -> None:
422 result = _merge(repo, "--abort", "--json")
423 data = json.loads(result.output)
424 assert data["error"] == "no_merge_in_progress"
425
426 def test_abort_restores_working_tree(self, conflict_repo: pathlib.Path) -> None:
427 original = (conflict_repo / "a.py").read_text()
428 _merge(conflict_repo, "feat") # leaves conflict state
429 # Verify conflict state was created
430 assert (merge_state_path(conflict_repo)).exists()
431 result = _merge(conflict_repo, "--abort")
432 assert result.exit_code == 0
433 # MERGE_STATE should be gone
434 assert not (merge_state_path(conflict_repo)).exists()
435
436 def test_abort_json_has_status(self, conflict_repo: pathlib.Path) -> None:
437 _merge(conflict_repo, "feat")
438 result = _merge(conflict_repo, "--abort", "--json")
439 assert result.exit_code == 0
440 data = json.loads(result.output)
441 assert data["status"] == "aborted"
442 assert "restored_to" in data
443
444 def test_abort_uses_read_merge_state(self, conflict_repo: pathlib.Path) -> None:
445 """_run_abort must use read_merge_state() not raw json.loads()."""
446 import inspect
447 from muse.cli.commands.merge import _run_abort
448
449 src = inspect.getsource(_run_abort)
450 assert "json.loads" not in src, (
451 "_run_abort must use read_merge_state() instead of raw json.loads() "
452 "to benefit from schema validation"
453 )
454 assert "read_merge_state" in src
455
456
457 # ──────────────────────────────────────────────────────────────────────────────
458 # Security — ANSI injection
459 # ──────────────────────────────────────────────────────────────────────────────
460
461
462 class TestSecurityAnsi:
463 ESC = "\x1b["
464
465 def test_error_routing_no_ansi_in_stdout(self, repo: pathlib.Path) -> None:
466 """Error messages for invalid operations must not bleed ANSI into stdout."""
467 result = _merge(repo, "main") # merge into itself
468 assert self.ESC not in result.output.replace(result.stderr or "", "")
469
470 def test_ansi_in_strategy_arg_sanitized_in_stderr(self, repo: pathlib.Path) -> None:
471 result = _merge(repo, "main", "--strategy", f"{self.ESC}31mxml{self.ESC}0m")
472 assert self.ESC not in (result.stderr or "")
473
474 def test_conflict_paths_sanitized_in_json(self, conflict_repo: pathlib.Path) -> None:
475 """Any ANSI that leaked into conflict_paths must be sanitized in JSON output."""
476 result = _merge(conflict_repo, "feat", "--json")
477 data = json.loads(result.output)
478 for path in data.get("conflicts", []):
479 assert self.ESC not in path
480
481 def test_merge_message_sanitized_in_commit(self, three_way_repo: pathlib.Path) -> None:
482 """Branch name embedded in merge commit message must be ANSI-clean."""
483 result = _merge(three_way_repo, "feat")
484 assert result.exit_code == 0
485 cid = get_head_commit_id(three_way_repo, "main")
486 assert cid is not None
487 commit = read_commit(three_way_repo, cid)
488 assert commit is not None
489 assert self.ESC not in commit.message
490
491 def test_applied_strategies_sanitized(self, three_way_repo: pathlib.Path) -> None:
492 """Any applied_strategies entries are sanitized before printing."""
493 result = _merge(three_way_repo, "feat")
494 assert self.ESC not in result.output
495
496
497 # ──────────────────────────────────────────────────────────────────────────────
498 # Integration — strategy shortcuts
499 # ──────────────────────────────────────────────────────────────────────────────
500
501
502 class TestStrategy:
503 def test_strategy_ours_resolves_conflict(self, conflict_repo: pathlib.Path) -> None:
504 result = _merge(conflict_repo, "feat", "--strategy", "ours")
505 assert result.exit_code == 0
506
507 def test_strategy_ours_keeps_our_content(self, conflict_repo: pathlib.Path) -> None:
508 _merge(conflict_repo, "feat", "--strategy", "ours")
509 content = (conflict_repo / "a.py").read_text()
510 assert "42" in content # main's version
511
512 def test_strategy_theirs_keeps_their_content(self, conflict_repo: pathlib.Path) -> None:
513 _merge(conflict_repo, "feat", "--strategy", "theirs")
514 content = (conflict_repo / "a.py").read_text()
515 assert "999" in content # feat's version
516
517 def test_strategy_ours_creates_merge_commit(self, conflict_repo: pathlib.Path) -> None:
518 before = get_head_commit_id(conflict_repo, "main")
519 _merge(conflict_repo, "feat", "--strategy", "ours")
520 after = get_head_commit_id(conflict_repo, "main")
521 assert after != before
522
523 def test_strategy_json_has_correct_strategy_field(self, conflict_repo: pathlib.Path) -> None:
524 result = _merge(conflict_repo, "feat", "--strategy", "ours", "--json")
525 data = json.loads(result.output)
526 assert data["strategy"] == "ours"
527
528 def test_strategy_json_has_files_changed(self, conflict_repo: pathlib.Path) -> None:
529 result = _merge(conflict_repo, "feat", "--strategy", "ours", "--json")
530 data = json.loads(result.output)
531 assert "files_changed" in data
532
533 def test_strategy_dry_run_does_not_write(self, conflict_repo: pathlib.Path) -> None:
534 before = get_head_commit_id(conflict_repo, "main")
535 # --dry-run bypasses strategy shortcuts; simulates three-way instead
536 result = _merge(conflict_repo, "feat", "--strategy", "ours", "--dry-run")
537 after = get_head_commit_id(conflict_repo, "main")
538 assert before == after
539
540
541 # ──────────────────────────────────────────────────────────────────────────────
542 # Stress
543 # ──────────────────────────────────────────────────────────────────────────────
544
545
546 @pytest.mark.slow
547 class TestStress:
548 def test_merge_100_file_branch_fast(self, repo: pathlib.Path) -> None:
549 """Merging 100 new files must complete in under 5s."""
550 _invoke(repo, ["branch", "big-feat"])
551 _invoke(repo, ["checkout", "big-feat"])
552 for i in range(100):
553 (repo / f"f{i:03d}.py").write_text(f"x={i}\n")
554 _commit(repo, "-m", "add 100 files")
555 _invoke(repo, ["checkout", "main"])
556 (repo / "main_extra.py").write_text("m=1\n")
557 _commit(repo, "-m", "main diverges")
558
559 t0 = time.perf_counter()
560 result = _merge(repo, "big-feat")
561 elapsed = (time.perf_counter() - t0) * 1000
562 assert result.exit_code == 0
563 assert elapsed < 5000, f"100-file merge took {elapsed:.0f}ms (limit 5s)"
564
565 def test_dry_run_100_file_branch_fast(self, repo: pathlib.Path) -> None:
566 """Dry-run of a 100-file merge must complete in under 3s."""
567 _invoke(repo, ["branch", "big-feat"])
568 _invoke(repo, ["checkout", "big-feat"])
569 for i in range(100):
570 (repo / f"g{i:03d}.py").write_text(f"y={i}\n")
571 _commit(repo, "-m", "add 100 files")
572 _invoke(repo, ["checkout", "main"])
573 (repo / "main_extra2.py").write_text("m=2\n")
574 _commit(repo, "-m", "main diverges")
575
576 t0 = time.perf_counter()
577 result = _merge(repo, "big-feat", "--dry-run")
578 elapsed = (time.perf_counter() - t0) * 1000
579 assert result.exit_code == 0
580 assert elapsed < 3000, f"100-file dry-run took {elapsed:.0f}ms (limit 3s)"
581
582 def test_abort_cycle_10_times(self, conflict_repo: pathlib.Path) -> None:
583 """Abort should cleanly reset MERGE_STATE each time."""
584 for i in range(10):
585 r_merge = _merge(conflict_repo, "feat")
586 assert r_merge.exit_code == 1 # conflict
587 assert (merge_state_path(conflict_repo)).exists()
588 r_abort = _merge(conflict_repo, "--abort")
589 assert r_abort.exit_code == 0
590 assert not (merge_state_path(conflict_repo)).exists()
591
592 def test_concurrent_merges_separate_repos(self, tmp_path: pathlib.Path) -> None:
593 """Multiple repos merging concurrently must not interfere."""
594 errors: list[str] = []
595
596 def do_merge(idx: int) -> None:
597 repo_dir = tmp_path / f"repo_{idx}"
598 repo_dir.mkdir()
599 subprocess.run(["muse", "init"], cwd=str(repo_dir), capture_output=True)
600 (repo_dir / "a.py").write_text(f"x={idx}\n")
601 subprocess.run(
602 ["muse", "commit", "-m", f"base{idx}"],
603 cwd=str(repo_dir), capture_output=True,
604 )
605 subprocess.run(
606 ["muse", "branch", "feat"], cwd=str(repo_dir), capture_output=True
607 )
608 subprocess.run(
609 ["muse", "checkout", "feat"], cwd=str(repo_dir), capture_output=True
610 )
611 (repo_dir / "b.py").write_text(f"y={idx}\n")
612 subprocess.run(
613 ["muse", "commit", "-m", f"feat{idx}"],
614 cwd=str(repo_dir), capture_output=True,
615 )
616 subprocess.run(
617 ["muse", "checkout", "main"], cwd=str(repo_dir), capture_output=True
618 )
619 r = subprocess.run(
620 ["muse", "merge", "feat", "--json"],
621 cwd=str(repo_dir), capture_output=True, text=True,
622 )
623 if r.returncode != 0:
624 errors.append(f"repo_{idx}: exit={r.returncode}")
625 return
626 try:
627 data = json.loads(r.stdout)
628 if data["status"] not in ("fast_forward", "merged"):
629 errors.append(f"repo_{idx}: unexpected status {data['status']}")
630 except Exception as e:
631 errors.append(f"repo_{idx}: {e}")
632
633 threads = [threading.Thread(target=do_merge, args=(i,)) for i in range(6)]
634 for t in threads:
635 t.start()
636 for t in threads:
637 t.join()
638 assert not errors, f"Concurrent merge errors:\n{'\n'.join(errors)}"
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 122 days ago