gabriel / muse public
test_cmd_reconcile.py python
753 lines 30.2 KB
Raw
sha256:ff478cfdcdd4b7fd6de89cb68896601a981f945634463275ec333bd20ca36402 Merge branch 'dev' into main Human 21 days ago
1 """Comprehensive tests for ``muse coord reconcile``.
2
3 Coverage
4 --------
5 Unit — internal helpers
6 _BranchSummary.to_dict: all keys present and correctly typed
7 strategy fast-forward: branch with 0 conflicts → fast-forward
8 strategy rebase: branch with 1–2 conflicts → rebase
9 strategy manual: branch with 3+ conflicts → manual
10 hotspot detection: same address on 2 branches → hotspot
11
12 Integration — CLI
13 empty repo: exits 0, "no active coordination data" in output
14 single branch: exits 0, branch name in output
15 multiple branches no conflict: each branch listed, 0 hotspots
16 multiple branches with hotspot: hotspot address in output
17 --json output schema: all required top-level keys present
18 --json shorthand: same schema as --format json
19 --format json explicit: same schema as --json
20
21 JSON schema
22 compact (single line): output is exactly one line
23 duration_ms present: field exists and is a non-negative float
24 duration_ms type: float, not string or int
25 branch entry has run_ids: each branch entry includes run_ids list
26 merge order matches branches: recommended_merge_order matches branch names
27
28 Security
29 ANSI in branch name (text): stripped from text output (merge order)
30 ANSI in branch name (hotspot): stripped from hotspot text
31 ANSI in address: stripped from text output
32
33 E2E — strategy thresholds
34 0 conflicts → fast-forward
35 1 conflict → rebase
36 2 conflicts → rebase
37 3 conflicts → manual
38
39 Stress
40 50 reservations 5 branches < 1 s: throughput baseline
41 100 reservations 10 branches: all in JSON, duration_ms present
42 hotspot-heavy 20 branches 1 addr: all share same address
43 """
44
45 from __future__ import annotations
46
47 type _AddrBranches = dict[str, list[str]]
48
49 import json
50 import pathlib
51 import time
52
53 import pytest
54
55 from tests.cli_test_helper import CliRunner
56 from muse.core.types import fake_id
57 from muse.core.paths import muse_dir
58 from muse.core.coordination import (
59 create_intent,
60 create_reservation,
61 active_reservations,
62 load_all_intents,
63 )
64 from muse.cli.commands.reconcile import _BranchSummary
65
66 cli = None
67 runner = CliRunner()
68
69 _REQUIRED_JSON_KEYS = {
70 "schema",
71 "active_reservations",
72 "active_intents",
73 "conflict_hotspots",
74 "branches",
75 "recommended_merge_order",
76 "strategies",
77 "hotspots",
78 }
79
80 _REQUIRED_BRANCH_KEYS = {
81 "branch",
82 "reserved_addresses",
83 "intents",
84 "run_ids",
85 "predicted_conflicts",
86 }
87
88
89 # ---------------------------------------------------------------------------
90 # Fixtures
91 # ---------------------------------------------------------------------------
92
93
94 @pytest.fixture()
95 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
96 dot_muse = muse_dir(tmp_path)
97 dot_muse.mkdir()
98 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
99 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
100 return tmp_path
101
102
103 # ---------------------------------------------------------------------------
104 # Helpers
105 # ---------------------------------------------------------------------------
106
107
108 def _make_reservation(
109 root: pathlib.Path,
110 *,
111 run_id: str = "agent-1",
112 branch: str = "main",
113 addresses: list[str] | None = None,
114 ttl_seconds: int = 3600,
115 ) -> None:
116 create_reservation(
117 root,
118 run_id=run_id,
119 branch=branch,
120 addresses=addresses or ["src/mod.py::foo"],
121 ttl_seconds=ttl_seconds,
122 )
123
124
125 def _make_intent(
126 root: pathlib.Path,
127 *,
128 run_id: str = "agent-1",
129 branch: str = "main",
130 addresses: list[str] | None = None,
131 operation: str = "modify",
132 ) -> None:
133 create_intent(
134 root,
135 reservation_id=fake_id("reconcile-intent-res"),
136 run_id=run_id,
137 branch=branch,
138 addresses=addresses or ["src/mod.py::foo"],
139 operation=operation,
140 )
141
142
143 # ---------------------------------------------------------------------------
144 # Unit — _BranchSummary
145 # ---------------------------------------------------------------------------
146
147
148 class TestBranchSummaryToDict:
149 def test_all_required_keys_present(self) -> None:
150 bs = _BranchSummary("feature/billing")
151 d = bs.to_dict()
152 assert _REQUIRED_BRANCH_KEYS.issubset(d.keys())
153
154 def test_branch_field_matches_constructor(self) -> None:
155 bs = _BranchSummary("feature/auth")
156 assert bs.to_dict()["branch"] == "feature/auth"
157
158 def test_reserved_addresses_starts_empty(self) -> None:
159 bs = _BranchSummary("main")
160 assert bs.to_dict()["reserved_addresses"] == []
161
162 def test_intents_starts_empty(self) -> None:
163 bs = _BranchSummary("main")
164 assert bs.to_dict()["intents"] == []
165
166 def test_run_ids_sorted(self) -> None:
167 bs = _BranchSummary("main")
168 bs.run_ids = {"agent-3", "agent-1", "agent-2"}
169 run_ids_list = bs.to_dict()["run_ids"]
170 assert run_ids_list == sorted(run_ids_list)
171
172 def test_predicted_conflicts_starts_zero(self) -> None:
173 bs = _BranchSummary("main")
174 assert bs.to_dict()["predicted_conflicts"] == 0
175
176 def test_conflict_count_reflected_in_dict(self) -> None:
177 bs = _BranchSummary("main")
178 bs.conflict_count = 5
179 assert bs.to_dict()["predicted_conflicts"] == 5
180
181 def test_reserved_addresses_reflects_mutations(self) -> None:
182 bs = _BranchSummary("main")
183 bs.reserved_addresses.extend(["src/a.py::foo", "src/b.py::bar"])
184 assert len(bs.to_dict()["reserved_addresses"]) == 2
185
186
187 class TestStrategySelection:
188 """Verify strategy labels for known conflict counts (mirrors reconcile.run logic)."""
189
190 def _strategy_for(self, conflict_count: int) -> str:
191 if conflict_count == 0:
192 return "fast-forward (no conflicts predicted)"
193 elif conflict_count <= 2:
194 return "rebase onto main before merging"
195 else:
196 return "manual conflict resolution required"
197
198 def test_zero_conflicts_fast_forward(self) -> None:
199 assert self._strategy_for(0) == "fast-forward (no conflicts predicted)"
200
201 def test_one_conflict_rebase(self) -> None:
202 assert self._strategy_for(1) == "rebase onto main before merging"
203
204 def test_two_conflicts_rebase(self) -> None:
205 assert self._strategy_for(2) == "rebase onto main before merging"
206
207 def test_three_conflicts_manual(self) -> None:
208 assert self._strategy_for(3) == "manual conflict resolution required"
209
210 def test_ten_conflicts_manual(self) -> None:
211 assert self._strategy_for(10) == "manual conflict resolution required"
212
213
214 class TestHotspotDetection:
215 def test_same_address_two_branches_is_hotspot(self, tmp_path: pathlib.Path) -> None:
216 _make_reservation(
217 tmp_path, run_id="agent-1", branch="feature/a",
218 addresses=["src/billing.py::compute_total"],
219 )
220 _make_reservation(
221 tmp_path, run_id="agent-2", branch="feature/b",
222 addresses=["src/billing.py::compute_total"],
223 )
224 reservations = active_reservations(tmp_path)
225 addr_branches: _AddrBranches = {}
226 for res in reservations:
227 for addr in res.addresses:
228 addr_branches.setdefault(addr, []).append(res.branch)
229 hotspots = {
230 addr: branches
231 for addr, branches in addr_branches.items()
232 if len(set(branches)) > 1
233 }
234 assert "src/billing.py::compute_total" in hotspots
235
236 def test_same_address_same_branch_not_hotspot(self, tmp_path: pathlib.Path) -> None:
237 _make_reservation(
238 tmp_path, run_id="agent-1", branch="feature/a",
239 addresses=["src/billing.py::compute_total"],
240 )
241 _make_reservation(
242 tmp_path, run_id="agent-2", branch="feature/a",
243 addresses=["src/billing.py::compute_total"],
244 )
245 reservations = active_reservations(tmp_path)
246 addr_branches: _AddrBranches = {}
247 for res in reservations:
248 for addr in res.addresses:
249 addr_branches.setdefault(addr, []).append(res.branch)
250 hotspots = {
251 addr: branches
252 for addr, branches in addr_branches.items()
253 if len(set(branches)) > 1
254 }
255 assert "src/billing.py::compute_total" not in hotspots
256
257 def test_distinct_addresses_no_hotspot(self, tmp_path: pathlib.Path) -> None:
258 _make_reservation(
259 tmp_path, run_id="agent-1", branch="feature/a",
260 addresses=["src/a.py::foo"],
261 )
262 _make_reservation(
263 tmp_path, run_id="agent-2", branch="feature/b",
264 addresses=["src/b.py::bar"],
265 )
266 reservations = active_reservations(tmp_path)
267 addr_branches: _AddrBranches = {}
268 for res in reservations:
269 for addr in res.addresses:
270 addr_branches.setdefault(addr, []).append(res.branch)
271 hotspots = {
272 addr: branches
273 for addr, branches in addr_branches.items()
274 if len(set(branches)) > 1
275 }
276 assert len(hotspots) == 0
277
278
279 # ---------------------------------------------------------------------------
280 # Integration — CLI
281 # ---------------------------------------------------------------------------
282
283
284 class TestReconcileCLIEmpty:
285 def test_empty_repo_exits_zero(self, repo: pathlib.Path) -> None:
286 result = runner.invoke(cli, ["coord", "reconcile"])
287 assert result.exit_code == 0
288
289 def test_empty_repo_prints_no_active_data(self, repo: pathlib.Path) -> None:
290 result = runner.invoke(cli, ["coord", "reconcile"])
291 assert "no active coordination data" in result.output
292
293 def test_empty_repo_json_exits_zero(self, repo: pathlib.Path) -> None:
294 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
295 assert result.exit_code == 0
296
297 def test_empty_repo_json_has_required_keys(self, repo: pathlib.Path) -> None:
298 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
299 data = json.loads(result.output)
300 assert _REQUIRED_JSON_KEYS.issubset(data.keys())
301
302 def test_empty_repo_json_counts_are_zero(self, repo: pathlib.Path) -> None:
303 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
304 data = json.loads(result.output)
305 assert data["active_reservations"] == 0
306 assert data["active_intents"] == 0
307 assert data["conflict_hotspots"] == 0
308
309
310 class TestReconcileCLISingleBranch:
311 def test_single_branch_exits_zero(self, repo: pathlib.Path) -> None:
312 _make_reservation(repo, run_id="agent-1", branch="feature/billing")
313 result = runner.invoke(cli, ["coord", "reconcile"])
314 assert result.exit_code == 0
315
316 def test_single_branch_name_in_output(self, repo: pathlib.Path) -> None:
317 _make_reservation(repo, run_id="agent-1", branch="feature/billing")
318 result = runner.invoke(cli, ["coord", "reconcile"])
319 assert "feature/billing" in result.output
320
321 def test_single_branch_no_hotspots(self, repo: pathlib.Path) -> None:
322 _make_reservation(repo, run_id="agent-1", branch="feature/billing")
323 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
324 data = json.loads(result.output)
325 assert data["conflict_hotspots"] == 0
326
327 def test_single_branch_fast_forward_strategy(self, repo: pathlib.Path) -> None:
328 _make_reservation(repo, run_id="agent-1", branch="feature/billing")
329 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
330 data = json.loads(result.output)
331 strategy = data["strategies"].get("feature/billing", "")
332 assert "fast-forward" in strategy
333
334
335 class TestReconcileCLIMultipleBranches:
336 def test_multiple_branches_no_conflict_exits_zero(self, repo: pathlib.Path) -> None:
337 _make_reservation(repo, run_id="agent-1", branch="feature/a", addresses=["src/a.py::foo"])
338 _make_reservation(repo, run_id="agent-2", branch="feature/b", addresses=["src/b.py::bar"])
339 result = runner.invoke(cli, ["coord", "reconcile"])
340 assert result.exit_code == 0
341
342 def test_multiple_branches_both_listed(self, repo: pathlib.Path) -> None:
343 _make_reservation(repo, run_id="agent-1", branch="feature/a", addresses=["src/a.py::foo"])
344 _make_reservation(repo, run_id="agent-2", branch="feature/b", addresses=["src/b.py::bar"])
345 result = runner.invoke(cli, ["coord", "reconcile"])
346 assert "feature/a" in result.output
347 assert "feature/b" in result.output
348
349 def test_multiple_branches_no_conflict_zero_hotspots(self, repo: pathlib.Path) -> None:
350 _make_reservation(repo, run_id="agent-1", branch="feature/a", addresses=["src/a.py::foo"])
351 _make_reservation(repo, run_id="agent-2", branch="feature/b", addresses=["src/b.py::bar"])
352 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
353 data = json.loads(result.output)
354 assert data["conflict_hotspots"] == 0
355
356 def test_hotspot_address_in_text_output(self, repo: pathlib.Path) -> None:
357 _make_reservation(
358 repo, run_id="agent-1", branch="feature/a",
359 addresses=["src/billing.py::compute_total"],
360 )
361 _make_reservation(
362 repo, run_id="agent-2", branch="feature/b",
363 addresses=["src/billing.py::compute_total"],
364 )
365 result = runner.invoke(cli, ["coord", "reconcile"])
366 assert "src/billing.py::compute_total" in result.output
367
368 def test_hotspot_count_nonzero_in_json(self, repo: pathlib.Path) -> None:
369 _make_reservation(
370 repo, run_id="agent-1", branch="feature/a",
371 addresses=["src/billing.py::compute_total"],
372 )
373 _make_reservation(
374 repo, run_id="agent-2", branch="feature/b",
375 addresses=["src/billing.py::compute_total"],
376 )
377 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
378 data = json.loads(result.output)
379 assert data["conflict_hotspots"] == 1
380
381 def test_hotspot_present_in_hotspots_list(self, repo: pathlib.Path) -> None:
382 _make_reservation(
383 repo, run_id="agent-1", branch="feature/a",
384 addresses=["src/billing.py::compute_total"],
385 )
386 _make_reservation(
387 repo, run_id="agent-2", branch="feature/b",
388 addresses=["src/billing.py::compute_total"],
389 )
390 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
391 data = json.loads(result.output)
392 addresses = [h["address"] for h in data["hotspots"]]
393 assert "src/billing.py::compute_total" in addresses
394
395
396 class TestReconcileCLIJSONSchema:
397 def test_json_flag_exits_zero(self, repo: pathlib.Path) -> None:
398 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
399 assert result.exit_code == 0
400
401 def test_json_flag_is_valid_json(self, repo: pathlib.Path) -> None:
402 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
403 data = json.loads(result.output)
404 assert isinstance(data, dict)
405
406 def test_json_flag_has_required_keys(self, repo: pathlib.Path) -> None:
407 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
408 data = json.loads(result.output)
409 assert _REQUIRED_JSON_KEYS.issubset(data.keys())
410
411 def test_format_json_explicit_has_required_keys(self, repo: pathlib.Path) -> None:
412 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
413 data = json.loads(result.output)
414 assert _REQUIRED_JSON_KEYS.issubset(data.keys())
415
416 def test_branches_field_is_list(self, repo: pathlib.Path) -> None:
417 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
418 data = json.loads(result.output)
419 assert isinstance(data["branches"], list)
420
421 def test_recommended_merge_order_is_list(self, repo: pathlib.Path) -> None:
422 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
423 data = json.loads(result.output)
424 assert isinstance(data["recommended_merge_order"], list)
425
426 def test_strategies_is_dict(self, repo: pathlib.Path) -> None:
427 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
428 data = json.loads(result.output)
429 assert isinstance(data["strategies"], dict)
430
431 def test_hotspots_is_list(self, repo: pathlib.Path) -> None:
432 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
433 data = json.loads(result.output)
434 assert isinstance(data["hotspots"], list)
435
436 def test_branch_entry_has_required_keys(self, repo: pathlib.Path) -> None:
437 _make_reservation(repo, run_id="agent-1", branch="feature/billing")
438 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
439 data = json.loads(result.output)
440 assert len(data["branches"]) >= 1
441 branch_entry = data["branches"][0]
442 assert _REQUIRED_BRANCH_KEYS.issubset(branch_entry.keys())
443
444 def test_schema_version_is_string(self, repo: pathlib.Path) -> None:
445 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
446 data = json.loads(result.output)
447 assert isinstance(data["schema"], int)
448
449 def test_active_reservations_count_matches(self, repo: pathlib.Path) -> None:
450 _make_reservation(repo, run_id="agent-1", branch="feature/a", addresses=["src/a.py::foo"])
451 _make_reservation(repo, run_id="agent-2", branch="feature/b", addresses=["src/b.py::bar"])
452 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
453 data = json.loads(result.output)
454 assert data["active_reservations"] == 2
455
456 def test_active_intents_count_matches(self, repo: pathlib.Path) -> None:
457 _make_intent(repo, run_id="agent-1", branch="feature/a")
458 _make_intent(repo, run_id="agent-2", branch="feature/b")
459 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
460 data = json.loads(result.output)
461 assert data["active_intents"] == 2
462
463
464 # ---------------------------------------------------------------------------
465 # Stress
466 # ---------------------------------------------------------------------------
467
468
469 class TestReconcileStress:
470 def test_50_reservations_5_branches_under_1_second(self, repo: pathlib.Path) -> None:
471 branches = [f"feature/branch-{i}" for i in range(5)]
472 start = time.monotonic()
473 for i in range(50):
474 create_reservation(
475 repo,
476 run_id=f"agent-{i}",
477 branch=branches[i % len(branches)],
478 addresses=[f"src/mod{i}.py::sym{i}"],
479 ttl_seconds=3600,
480 )
481 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
482 elapsed = time.monotonic() - start
483 assert result.exit_code == 0
484 data = json.loads(result.output)
485 assert data["active_reservations"] == 50
486 assert elapsed < 1.0, f"50 reservations across 5 branches took {elapsed:.2f}s (limit 1s)"
487
488 def test_100_reservations_10_branches_json_complete(self, repo: pathlib.Path) -> None:
489 branches = [f"feature/branch-{i}" for i in range(10)]
490 for i in range(100):
491 create_reservation(
492 repo,
493 run_id=f"agent-{i}",
494 branch=branches[i % len(branches)],
495 addresses=[f"src/mod{i}.py::sym{i}"],
496 ttl_seconds=3600,
497 )
498 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
499 assert result.exit_code == 0
500 data = json.loads(result.output)
501 assert data["active_reservations"] == 100
502 assert "duration_ms" in data
503 assert isinstance(data["duration_ms"], float)
504 assert len(data["branches"]) == 10
505
506 def test_hotspot_heavy_20_branches_all_share_one_address(
507 self, repo: pathlib.Path
508 ) -> None:
509 shared = "src/core.py::shared_sym"
510 for i in range(20):
511 create_reservation(
512 repo,
513 run_id=f"agent-{i}",
514 branch=f"feature/branch-{i}",
515 addresses=[shared],
516 ttl_seconds=3600,
517 )
518 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
519 assert result.exit_code == 0
520 data = json.loads(result.output)
521 assert data["conflict_hotspots"] == 1
522 assert data["hotspots"][0]["address"] == shared
523 assert len(data["hotspots"][0]["branches"]) == 20
524
525
526 # ---------------------------------------------------------------------------
527 # JSON schema — compact + duration_ms
528 # ---------------------------------------------------------------------------
529
530
531 class TestReconcileJsonCompact:
532 def test_json_output_is_single_line(self, repo: pathlib.Path) -> None:
533 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
534 assert result.exit_code == 0
535 lines = [ln for ln in result.output.splitlines() if ln.strip()]
536 assert len(lines) == 1, f"Expected 1 JSON line, got {len(lines)}: {result.output!r}"
537
538 def test_duration_ms_present(self, repo: pathlib.Path) -> None:
539 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
540 data = json.loads(result.output)
541 assert "duration_ms" in data
542
543 def test_duration_ms_is_float(self, repo: pathlib.Path) -> None:
544 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
545 data = json.loads(result.output)
546 assert isinstance(data["duration_ms"], float)
547
548 def test_duration_ms_non_negative(self, repo: pathlib.Path) -> None:
549 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
550 data = json.loads(result.output)
551 assert data["duration_ms"] >= 0.0
552
553 def test_branch_entry_has_run_ids(self, repo: pathlib.Path) -> None:
554 _make_reservation(repo, run_id="agent-x", branch="feature/x")
555 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
556 data = json.loads(result.output)
557 branch_entry = data["branches"][0]
558 assert "run_ids" in branch_entry
559 assert isinstance(branch_entry["run_ids"], list)
560 assert "agent-x" in branch_entry["run_ids"]
561
562 def test_merge_order_matches_branch_names(self, repo: pathlib.Path) -> None:
563 _make_reservation(repo, run_id="agent-1", branch="feature/a", addresses=["src/a.py::foo"])
564 _make_reservation(repo, run_id="agent-2", branch="feature/b", addresses=["src/b.py::bar"])
565 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
566 data = json.loads(result.output)
567 branch_names = {entry["branch"] for entry in data["branches"]}
568 assert set(data["recommended_merge_order"]) == branch_names
569
570 def test_json_shorthand_same_structure_as_format_json(
571 self, repo: pathlib.Path
572 ) -> None:
573 _make_reservation(repo, run_id="agent-1", branch="feature/a")
574 r1 = runner.invoke(cli, ["coord", "reconcile", "--json"])
575 r2 = runner.invoke(cli, ["coord", "reconcile", "--json"])
576 d1 = json.loads(r1.output)
577 d2 = json.loads(r2.output)
578 structural_keys = {
579 "schema", "active_reservations", "active_intents",
580 "conflict_hotspots", "recommended_merge_order", "strategies",
581 }
582 for key in structural_keys:
583 assert d1[key] == d2[key], f"Mismatch on {key!r}"
584
585
586 # ---------------------------------------------------------------------------
587 # E2E — strategy thresholds
588 # ---------------------------------------------------------------------------
589
590
591 class TestReconcileE2EStrategies:
592 def test_zero_conflicts_fast_forward(self, repo: pathlib.Path) -> None:
593 _make_reservation(repo, run_id="agent-1", branch="feature/clean", addresses=["src/a.py::foo"])
594 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
595 data = json.loads(result.output)
596 assert "fast-forward" in data["strategies"]["feature/clean"]
597
598 def test_one_conflict_rebase(self, repo: pathlib.Path) -> None:
599 _make_reservation(repo, run_id="agent-1", branch="feature/a", addresses=["src/shared.py::x"])
600 _make_reservation(repo, run_id="agent-2", branch="feature/b", addresses=["src/shared.py::x"])
601 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
602 data = json.loads(result.output)
603 for branch in ("feature/a", "feature/b"):
604 assert "rebase" in data["strategies"][branch]
605
606 def test_two_conflicts_still_rebase(self, repo: pathlib.Path) -> None:
607 # Two hotspot addresses shared between same two branches → conflict_count == 2.
608 _make_reservation(
609 repo, run_id="agent-1", branch="feature/a",
610 addresses=["src/shared.py::x", "src/shared.py::y"],
611 )
612 _make_reservation(
613 repo, run_id="agent-2", branch="feature/b",
614 addresses=["src/shared.py::x", "src/shared.py::y"],
615 )
616 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
617 data = json.loads(result.output)
618 for branch in ("feature/a", "feature/b"):
619 assert "rebase" in data["strategies"][branch]
620
621 def test_three_conflicts_manual(self, repo: pathlib.Path) -> None:
622 shared_addrs = [f"src/shared.py::sym{i}" for i in range(3)]
623 _make_reservation(repo, run_id="agent-1", branch="feature/a", addresses=shared_addrs)
624 _make_reservation(repo, run_id="agent-2", branch="feature/b", addresses=shared_addrs)
625 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
626 data = json.loads(result.output)
627 for branch in ("feature/a", "feature/b"):
628 assert "manual" in data["strategies"][branch]
629
630 def test_merge_order_clean_branch_first(self, repo: pathlib.Path) -> None:
631 _make_reservation(repo, run_id="agent-1", branch="feature/clean", addresses=["src/a.py::foo"])
632 _make_reservation(repo, run_id="agent-2", branch="feature/conflict-a", addresses=["src/shared.py::x"])
633 _make_reservation(repo, run_id="agent-3", branch="feature/conflict-b", addresses=["src/shared.py::x"])
634 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
635 data = json.loads(result.output)
636 order = data["recommended_merge_order"]
637 assert order.index("feature/clean") < order.index("feature/conflict-a")
638 assert order.index("feature/clean") < order.index("feature/conflict-b")
639
640 def test_intents_count_in_json(self, repo: pathlib.Path) -> None:
641 _make_intent(repo, run_id="agent-1", branch="feature/x", operation="rename")
642 _make_intent(repo, run_id="agent-2", branch="feature/x", operation="modify")
643 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
644 data = json.loads(result.output)
645 assert data["active_intents"] == 2
646
647 def test_branch_intents_list_populated(self, repo: pathlib.Path) -> None:
648 _make_intent(repo, run_id="agent-1", branch="feature/x", operation="rename")
649 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
650 data = json.loads(result.output)
651 branch_entry = next(b for b in data["branches"] if b["branch"] == "feature/x")
652 assert "rename" in branch_entry["intents"]
653
654 def test_text_output_includes_elapsed(self, repo: pathlib.Path) -> None:
655 _make_reservation(repo, run_id="agent-1", branch="feature/a")
656 result = runner.invoke(cli, ["coord", "reconcile"])
657 assert result.exit_code == 0
658 # Elapsed appears at the bottom as (X.XXXs)
659 assert "s)" in result.output
660
661
662 # ---------------------------------------------------------------------------
663 # Security — merge order branch sanitization
664 # ---------------------------------------------------------------------------
665
666
667 class TestReconcileMergeOrderSecurity:
668 def test_ansi_in_branch_stripped_from_merge_order(self, repo: pathlib.Path) -> None:
669 ansi_branch = "\x1b[31mfeature/malicious\x1b[0m"
670 create_reservation(
671 repo,
672 run_id="agent-malicious",
673 branch=ansi_branch,
674 addresses=["src/mod.py::foo"],
675 ttl_seconds=3600,
676 )
677 result = runner.invoke(cli, ["coord", "reconcile"])
678 assert result.exit_code == 0
679 assert "\x1b[31m" not in result.output
680 assert "\x1b[0m" not in result.output
681
682 def test_ansi_in_branch_stripped_from_hotspot_text(self, repo: pathlib.Path) -> None:
683 ansi_branch = "\x1b[35mfeature/badactor\x1b[0m"
684 create_reservation(
685 repo,
686 run_id="agent-1",
687 branch=ansi_branch,
688 addresses=["src/billing.py::compute_total"],
689 ttl_seconds=3600,
690 )
691 create_reservation(
692 repo,
693 run_id="agent-2",
694 branch="feature/clean",
695 addresses=["src/billing.py::compute_total"],
696 ttl_seconds=3600,
697 )
698 result = runner.invoke(cli, ["coord", "reconcile"])
699 assert result.exit_code == 0
700 assert "\x1b[35m" not in result.output
701 assert "\x1b[0m" not in result.output
702
703 def test_ansi_in_address_stripped_from_hotspot_text(
704 self, repo: pathlib.Path
705 ) -> None:
706 ansi_addr = "\x1b[32msrc/billing.py::compute_total\x1b[0m"
707 create_reservation(
708 repo,
709 run_id="agent-1",
710 branch="feature/a",
711 addresses=[ansi_addr],
712 ttl_seconds=3600,
713 )
714 create_reservation(
715 repo,
716 run_id="agent-2",
717 branch="feature/b",
718 addresses=[ansi_addr],
719 ttl_seconds=3600,
720 )
721 result = runner.invoke(cli, ["coord", "reconcile"])
722 assert result.exit_code == 0
723 assert "\x1b[32m" not in result.output
724 assert "\x1b[0m" not in result.output
725
726
727 class TestRegisterFlags:
728 def test_default_json_out_is_false(self) -> None:
729 import argparse
730 from muse.cli.commands.reconcile import register
731 p = argparse.ArgumentParser()
732 subs = p.add_subparsers()
733 register(subs)
734 args = p.parse_args(["reconcile"])
735 assert args.json_out is False
736
737 def test_json_flag_sets_json_out(self) -> None:
738 import argparse
739 from muse.cli.commands.reconcile import register
740 p = argparse.ArgumentParser()
741 subs = p.add_subparsers()
742 register(subs)
743 args = p.parse_args(["reconcile", "--json"])
744 assert args.json_out is True
745
746 def test_j_shorthand_sets_json_out(self) -> None:
747 import argparse
748 from muse.cli.commands.reconcile import register
749 p = argparse.ArgumentParser()
750 subs = p.add_subparsers()
751 register(subs)
752 args = p.parse_args(["reconcile", "-j"])
753 assert args.json_out is True
File History 2 commits
sha256:ff478cfdcdd4b7fd6de89cb68896601a981f945634463275ec333bd20ca36402 Merge branch 'dev' into main Human 21 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 73 days ago