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