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