gabriel / muse public
test_cmd_merge_hardening.py python
642 lines 28.5 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 return _invoke(repo, ["commit", *extra])
67
68
69 @pytest.fixture()
70 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
71 """Initialised repo with one commit on ``main``."""
72 saved = os.getcwd()
73 try:
74 os.chdir(tmp_path)
75 runner.invoke(None, ["init"])
76 finally:
77 os.chdir(saved)
78 (tmp_path / "a.py").write_text("x = 1\n")
79 _commit(tmp_path, "-m", "initial")
80 return tmp_path
81
82
83 @pytest.fixture()
84 def ff_repo(repo: pathlib.Path) -> pathlib.Path:
85 """Repo where ``feat`` is strictly ahead of ``main`` → fast-forward."""
86 _invoke(repo, ["branch", "feat"])
87 _invoke(repo, ["checkout", "feat"])
88 (repo / "b.py").write_text("y = 2\n")
89 _commit(repo, "-m", "feat add b")
90 _invoke(repo, ["checkout", "main"])
91 return repo
92
93
94 @pytest.fixture()
95 def three_way_repo(repo: pathlib.Path) -> pathlib.Path:
96 """Repo requiring a clean three-way merge (both sides diverged)."""
97 _invoke(repo, ["branch", "feat"])
98 _invoke(repo, ["checkout", "feat"])
99 (repo / "b.py").write_text("y = 2\n")
100 _commit(repo, "-m", "feat add b")
101 _invoke(repo, ["checkout", "main"])
102 (repo / "c.py").write_text("z = 3\n")
103 _commit(repo, "-m", "main add c")
104 return repo
105
106
107 @pytest.fixture()
108 def conflict_repo(repo: pathlib.Path) -> pathlib.Path:
109 """Repo where both sides modified the same file — conflict."""
110 _invoke(repo, ["branch", "feat"])
111 _invoke(repo, ["checkout", "feat"])
112 (repo / "a.py").write_text("x = 999\n")
113 _commit(repo, "-m", "feat modify a")
114 _invoke(repo, ["checkout", "main"])
115 (repo / "a.py").write_text("x = 42\n")
116 _commit(repo, "-m", "main modify a")
117 return repo
118
119
120 # ──────────────────────────────────────────────────────────────────────────────
121 # Unit — parser flags
122 # ──────────────────────────────────────────────────────────────────────────────
123
124
125 class TestRegisterFlags:
126 def _parse(self, *args: str) -> "argparse.Namespace":
127 import argparse
128
129 from muse.cli.commands.merge import register
130
131 p = argparse.ArgumentParser()
132 sub = p.add_subparsers()
133 register(sub)
134 return p.parse_args(["merge", *args])
135
136 def test_default_json_is_false(self) -> None:
137 ns = self._parse("feat")
138 assert ns.json_out is False
139
140 def test_json_flag_sets_json_out(self) -> None:
141 ns = self._parse("feat", "--json")
142 assert ns.json_out is True
143
144 def test_dry_run_default_false(self) -> None:
145 ns = self._parse("feat")
146 assert ns.dry_run is False
147
148 def test_dry_run_flag(self) -> None:
149 ns = self._parse("feat", "--dry-run")
150 assert ns.dry_run is True
151
152 def test_strategy_default_none(self) -> None:
153 ns = self._parse("feat")
154 assert ns.strategy is None
155
156 def test_strategy_ours(self) -> None:
157 ns = self._parse("feat", "--strategy", "ours")
158 assert ns.strategy == "ours"
159
160 def test_strategy_theirs(self) -> None:
161 ns = self._parse("feat", "--strategy", "theirs")
162 assert ns.strategy == "theirs"
163
164 def test_no_ff_default_false(self) -> None:
165 ns = self._parse("feat")
166 assert ns.no_ff is False
167
168 def test_abort_default_false(self) -> None:
169 ns = self._parse()
170 assert ns.abort is False
171
172 def test_harmony_autoupdate_default_true(self) -> None:
173 ns = self._parse("feat")
174 assert ns.harmony_autoupdate is True
175
176 def test_no_harmony_autoupdate(self) -> None:
177 ns = self._parse("feat", "--no-harmony-autoupdate")
178 assert ns.harmony_autoupdate is False
179
180
181 # ──────────────────────────────────────────────────────────────────────────────
182 # Unit — dead-code removal
183 # ──────────────────────────────────────────────────────────────────────────────
184
185
186 class TestDeadCodeRemoved:
187 def test_read_branch_wrapper_removed(self) -> None:
188 import muse.cli.commands.merge as m
189
190 assert not hasattr(m, "_read_branch"), (
191 "_read_branch was a dead one-liner wrapper and must be deleted"
192 )
193
194 def test_restore_from_manifest_wrapper_removed(self) -> None:
195 import muse.cli.commands.merge as m
196
197 assert not hasattr(m, "_restore_from_manifest"), (
198 "_restore_from_manifest was a dead one-liner wrapper and must be deleted"
199 )
200
201
202 # ──────────────────────────────────────────────────────────────────────────────
203 # Unit — _use_color
204 # ──────────────────────────────────────────────────────────────────────────────
205
206
207 class TestUseColor:
208 def test_no_color_env_disables_color(self, monkeypatch: pytest.MonkeyPatch) -> None:
209 from muse.core.terminal import use_color
210
211 monkeypatch.setenv("NO_COLOR", "1")
212 assert use_color() is False
213
214 def test_term_dumb_disables_color(self, monkeypatch: pytest.MonkeyPatch) -> None:
215 from muse.core.terminal import use_color
216
217 monkeypatch.setenv("TERM", "dumb")
218 assert use_color() is False
219
220 def test_c_helper_respects_use_color(self, monkeypatch: pytest.MonkeyPatch) -> None:
221 from muse.cli.commands.merge import _c, _GREEN
222
223 monkeypatch.setenv("NO_COLOR", "1")
224 result = _c("hello", _GREEN)
225 assert "\x1b[" not in result
226 assert result == "hello"
227
228
229 # ──────────────────────────────────────────────────────────────────────────────
230 # Unit — _semver_from_op_log
231 # ──────────────────────────────────────────────────────────────────────────────
232
233
234 class TestSemverFromOpLog:
235 """Verify _semver_from_op_log with an empty list (the only type-safe call site).
236 Non-empty behaviour is exercised via dry-run integration tests below,
237 which receive semver_impact in the JSON output after a real plugin diff."""
238
239 def test_empty_returns_empty(self) -> None:
240 from muse.cli.commands.merge import _semver_from_op_log
241
242 assert _semver_from_op_log([]) == ""
243
244 def test_semver_impact_present_in_dry_run_json(
245 self, three_way_repo: pathlib.Path
246 ) -> None:
247 """semver_impact is always a str in the dry-run JSON schema."""
248 result = _merge(three_way_repo, "feat", "--dry-run", "--json")
249 data = json.loads(result.output)
250 assert "semver_impact" in data
251 assert isinstance(data["semver_impact"], str)
252
253 def test_semver_impact_present_in_live_merge_json(
254 self, three_way_repo: pathlib.Path
255 ) -> None:
256 result = _merge(three_way_repo, "feat", "--json")
257 assert result.exit_code == 0
258 data = json.loads(result.output)
259 assert isinstance(data["semver_impact"], str)
260
261
262 # ──────────────────────────────────────────────────────────────────────────────
263 # Integration — error routing to stderr
264 # ──────────────────────────────────────────────────────────────────────────────
265
266
267 class TestErrorRouting:
268 def test_merge_itself_error_to_stderr(self, repo: pathlib.Path) -> None:
269 result = _merge(repo, "main")
270 assert result.exit_code == 1
271 assert "Cannot merge" in (result.stderr or "")
272 assert "Cannot merge" not in result.output.replace(result.stderr or "", "")
273
274 def test_no_branch_arg_error_to_stderr(self, repo: pathlib.Path) -> None:
275 result = _merge(repo)
276 assert result.exit_code == 1
277 assert "Usage" in (result.stderr or "")
278
279 def test_nonexistent_branch_error_to_stderr(self, repo: pathlib.Path) -> None:
280 result = _merge(repo, "ghost-branch")
281 assert result.exit_code == 1
282 assert "no commits" in (result.stderr or "").lower()
283
284 def test_unrecognized_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
285 result = _merge(repo, "main", "--no-such-flag")
286 assert result.exit_code != 0
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 data["commit_id"].startswith("sha256:") # canonical OID
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 (merge_state_path(conflict_repo)).exists()
435 result = _merge(conflict_repo, "--abort")
436 assert result.exit_code == 0
437 # MERGE_STATE should be gone
438 assert not (merge_state_path(conflict_repo)).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_ansi_in_strategy_arg_sanitized_in_stderr(self, repo: pathlib.Path) -> None:
475 result = _merge(repo, "main", "--strategy", 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 (merge_state_path(conflict_repo)).exists()
592 r_abort = _merge(conflict_repo, "--abort")
593 assert r_abort.exit_code == 0
594 assert not (merge_state_path(conflict_repo)).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, f"Concurrent merge errors:\n{'\n'.join(errors)}"
File History 1 commit