diff --git a/src/bonsai/bonsai/bim/module/ifcgit/prop.py b/src/bonsai/bonsai/bim/module/ifcgit/prop.py index 494da26f35..b9ac2a7739 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/prop.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/prop.py @@ -144,6 +144,11 @@ class IfcGitProperties(PropertyGroup): description="JSON report from last failed merge attempt", default="", ) + merge_resolutions: StringProperty( + name="Merge Resolutions", + description="JSON report of conflicts auto-resolved by the last merge", + default="", + ) if TYPE_CHECKING: ifcgit_commits: bpy.types.bpy_prop_collection_idprop[IfcGitListItem] @@ -159,3 +164,4 @@ class IfcGitProperties(PropertyGroup): select_remote: str ifcgit_filter: Literal["all", "tagged", "relevant"] merge_conflicts: str + merge_resolutions: str diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py index 901dee31ad..ec7890c4df 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -13,6 +13,9 @@ from bonsai.bim.module.ifcgit.data import IfcGitData if TYPE_CHECKING: from bonsai.bim.module.ifcgit.prop import IfcGitListItem, IfcGitProperties +# Conflict reports can contain thousands of entries; only this many rows are drawn +MAX_REPORT_ROWS = 50 + class IFCGIT_PT_panel(bpy.types.Panel): """Scene Properties panel to interact with IFC repository data""" @@ -143,7 +146,7 @@ class IFCGIT_PT_panel(bpy.types.Panel): text=f"Merge failed \u2014 {len(conflicts)} conflict(s)", icon="ERROR", ) - for conflict in conflicts: + for conflict in conflicts[:MAX_REPORT_ROWS]: col = box.column(align=True) conflict_type = conflict.get("type", "") entity_id = conflict.get("entity_id", "?") @@ -184,6 +187,40 @@ class IFCGIT_PT_panel(bpy.types.Panel): sub.label(text=f" Base: {conflict.get('base_value', '')}") sub.label(text=f" Local: {conflict.get('local_value', '')}") sub.label(text=f" Remote: {conflict.get('remote_value', '')}") + if len(conflicts) > MAX_REPORT_ROWS: + row = box.row() + row.label(text=f"... and {len(conflicts) - MAX_REPORT_ROWS} more conflicts") + + resolutions = tool.IfcGit.get_merge_resolutions() + if resolutions: + box = layout.box() + row = box.row() + row.label( + text=f"{len(resolutions)} conflict(s) auto-resolved by keeping one branch", + icon="INFO", + ) + for resolution in resolutions[:MAX_REPORT_ROWS]: + entity_id = resolution.get("entity_id") + entity_class = resolution.get("entity_class", "Entity") + kept = resolution.get("kept", "") + kept_label = {"local": "working branch", "remote": "incoming branch"}.get(kept, "one branch") + if resolution.get("type") == "placement_auto_resolved": + desc = f"#{entity_id} {entity_class}: moved in both branches, kept {kept_label} position" + else: + desc = f"#{entity_id} {entity_class}: " + resolution.get("message", "auto-resolved") + row = box.row(align=True) + row.label(text=desc) + select_id = resolution.get("original_local_id") or entity_id + if isinstance(select_id, int): + op = row.operator( + "ifcgit.select_conflict_entity", + text="", + icon="RESTRICT_SELECT_OFF", + ) + op.step_id = select_id + if len(resolutions) > MAX_REPORT_ROWS: + row = box.row() + row.label(text=f"... and {len(resolutions) - MAX_REPORT_ROWS} more") if not props.ifcgit_commits: return diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index 875a37f8c0..0c0ebe2969 100644 --- a/src/bonsai/bonsai/core/ifcgit.py +++ b/src/bonsai/bonsai/core/ifcgit.py @@ -99,6 +99,7 @@ def push(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, operator: def refresh_revision_list(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None: ifcgit.clear_merge_conflicts() + ifcgit.clear_merge_resolutions() if ifcgit.repo_has_commits(): ifcgit.refresh_revision_list(ifc.get_path()) @@ -144,15 +145,17 @@ def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.t mergetool = ifcgit.get_merge_tool(branch_name) merge_result = ifcgit.git_merge(branch_name) + resolutions = [] if merge_result == "error": operator.report({"ERROR"}, "Unknown IFC Merge failure") return False elif merge_result == "conflict": - conflicts = ifcgit.git_mergetool(mergetool, path_ifc) + conflicts, resolutions = ifcgit.git_mergetool(mergetool, path_ifc) if conflicts is not None: ifcgit.git_merge_abort() if conflicts: ifcgit.store_merge_conflicts(conflicts) + ifcgit.store_merge_resolutions(resolutions) operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below") else: operator.report({"ERROR"}, "Merge tool failed — check that ifcmerge is installed correctly") @@ -165,6 +168,13 @@ def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.t ifcgit.load_project(path_ifc) ifcgit.refresh_revision_list(path_ifc) ifcgit.decolourise() + # store last: changing the display branch triggers a refresh that clears the report + ifcgit.store_merge_resolutions(resolutions) + if resolutions: + operator.report( + {"WARNING"}, + f"Merge complete: {len(resolutions)} conflict(s) auto-resolved, see the report in the panel below", + ) def dry_run_merge(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> None: @@ -187,14 +197,21 @@ def dry_run_merge(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy. return if merge_result == "conflict": - conflicts = ifcgit.git_mergetool(mergetool, path_ifc) + conflicts, resolutions = ifcgit.git_mergetool(mergetool, path_ifc) ifcgit.git_merge_abort() + ifcgit.store_merge_resolutions(resolutions) if conflicts is not None: ifcgit.store_merge_conflicts(conflicts) operator.report({"WARNING"}, "Merge preview: conflicts found — see the panel below") else: ifcgit.clear_merge_conflicts() - operator.report({"INFO"}, "Merge preview: no conflicts") + if resolutions: + operator.report( + {"WARNING"}, + f"Merge preview: {len(resolutions)} conflict(s) would be auto-resolved, see the panel below", + ) + else: + operator.report({"INFO"}, "Merge preview: no conflicts") else: # Clean merge or already up to date — abort the pending merge state if any try: @@ -202,6 +219,7 @@ def dry_run_merge(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy. except Exception: pass ifcgit.clear_merge_conflicts() + ifcgit.clear_merge_resolutions() operator.report({"INFO"}, "Merge preview: no conflicts") diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 51120ad088..d2f31ffced 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -589,6 +589,9 @@ class IfcGit: def store_merge_conflicts(cls, conflicts): pass def clear_merge_conflicts(cls): pass def get_merge_conflicts(cls): pass + def store_merge_resolutions(cls, resolutions): pass + def clear_merge_resolutions(cls): pass + def get_merge_resolutions(cls): pass def set_display_branch(cls): pass def get_active_branch_name(cls): pass def get_ifcgit_props(cls): pass diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 4ceb6fb5e5..8ec65c0707 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -620,8 +620,11 @@ class IfcGit(bonsai.core.tool.IfcGit): return "error" @classmethod - def git_mergetool(cls, mergetool: str, path_ifc: str) -> Union[list, None]: - """Run ifcmerge tool. Returns None on success, list of conflict dicts on failure.""" + def git_mergetool(cls, mergetool: str, path_ifc: str) -> tuple[Union[list, None], list]: + """Run ifcmerge tool. Returns (conflicts, resolutions) where conflicts is + None on success or a list of conflict dicts on failure, and resolutions is + a list of conflicts ifcmerge resolved automatically (such as an object + moved in both branches, where one placement is kept).""" repo = IfcGitRepo.repo report_path = path_ifc + ".ifcmerge" try: @@ -630,13 +633,19 @@ class IfcGit(bonsai.core.tool.IfcGit): print(f"ifcgit: mergetool failed: {e}") conflicts = None + resolutions = [] if os.path.exists(report_path): try: with open(report_path) as f: content = f.read().strip() if content: + # older ifcmerge writes plain "Success!" on success, + # which is not JSON and means no report is available data = json.loads(content) - conflicts = data.get("conflicts", []) + if isinstance(data, dict): + resolutions = data.get("resolved", []) + if data.get("status") != "success": + conflicts = data.get("conflicts", []) except (json.JSONDecodeError, OSError): pass try: @@ -647,7 +656,7 @@ class IfcGit(bonsai.core.tool.IfcGit): if conflicts is None and repo.index.unmerged_blobs(): conflicts = [] - return conflicts + return conflicts, resolutions @classmethod def store_merge_conflicts(cls, conflicts: list) -> None: @@ -667,6 +676,24 @@ class IfcGit(bonsai.core.tool.IfcGit): except json.JSONDecodeError: return None + @classmethod + def store_merge_resolutions(cls, resolutions: list) -> None: + cls.get_ifcgit_props().merge_resolutions = json.dumps(resolutions) if resolutions else "" + + @classmethod + def clear_merge_resolutions(cls) -> None: + cls.get_ifcgit_props().merge_resolutions = "" + + @classmethod + def get_merge_resolutions(cls) -> Union[list, None]: + raw = cls.get_ifcgit_props().merge_resolutions + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + @classmethod def git_merge_abort(cls) -> None: IfcGitRepo.repo.git.merge(abort=True) diff --git a/src/bonsai/test/core/test_ifcgit.py b/src/bonsai/test/core/test_ifcgit.py index 4884497c8b..c95827b6d2 100644 --- a/src/bonsai/test/core/test_ifcgit.py +++ b/src/bonsai/test/core/test_ifcgit.py @@ -133,6 +133,7 @@ class TestPush: class TestRefreshRevisionList: def test_refreshes_when_repo_has_heads(self, ifcgit, ifc): ifcgit.clear_merge_conflicts().should_be_called() + ifcgit.clear_merge_resolutions().should_be_called() ifcgit.repo_has_commits().should_be_called().will_return(True) ifc.get_path().should_be_called().will_return("path/to/model.ifc") ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called() @@ -140,6 +141,7 @@ class TestRefreshRevisionList: def test_skips_when_repo_has_no_heads(self, ifcgit, ifc): ifcgit.clear_merge_conflicts().should_be_called() + ifcgit.clear_merge_resolutions().should_be_called() ifcgit.repo_has_commits().should_be_called().will_return(False) subject.refresh_revision_list(ifcgit, ifc) # nothing else should be called — Prophecy will verify @@ -197,6 +199,7 @@ class TestMergeBranch: ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") ifcgit.git_merge("feature").should_be_called().will_return(None) ifcgit.clear_merge_conflicts().should_be_called() + ifcgit.store_merge_resolutions([]).should_be_called() ifcgit.set_display_branch().should_be_called() ifcgit.git_checkout("path/to/model.ifc").should_be_called() ifcgit.load_project("path/to/model.ifc").should_be_called() @@ -210,9 +213,10 @@ class TestMergeBranch: ifcgit.get_selected_branch().should_be_called().will_return("feature") ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") ifcgit.git_merge("feature").should_be_called().will_return("conflict") - ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(None) + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return((None, [])) ifcgit.commit_merge("path/to/model.ifc").should_be_called() ifcgit.clear_merge_conflicts().should_be_called() + ifcgit.store_merge_resolutions([]).should_be_called() ifcgit.set_display_branch().should_be_called() ifcgit.git_checkout("path/to/model.ifc").should_be_called() ifcgit.load_project("path/to/model.ifc").should_be_called() @@ -220,6 +224,30 @@ class TestMergeBranch: ifcgit.decolourise().should_be_called() subject.merge_branch(ifcgit, ifc, operator=None) + def test_conflict_mergetool_success_with_resolutions(self, ifcgit, ifc): + resolutions = [{"type": "placement_auto_resolved", "entity_id": 15, "kept": "remote"}] + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge("feature").should_be_called().will_return("conflict") + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return( + (None, resolutions) + ) + ifcgit.commit_merge("path/to/model.ifc").should_be_called() + ifcgit.clear_merge_conflicts().should_be_called() + ifcgit.store_merge_resolutions(resolutions).should_be_called() + ifcgit.set_display_branch().should_be_called() + ifcgit.git_checkout("path/to/model.ifc").should_be_called() + ifcgit.load_project("path/to/model.ifc").should_be_called() + ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called() + ifcgit.decolourise().should_be_called() + op = MockOperator() + subject.merge_branch(ifcgit, ifc, op) + assert op.reports == [ + ({"WARNING"}, "Merge complete: 1 conflict(s) auto-resolved, see the report in the panel below") + ] + def test_conflict_mergetool_failure(self, ifcgit, ifc): conflicts = [{"type": "attribute_conflict", "entity_id": 42}] ifc.get_path().should_be_called().will_return("path/to/model.ifc") @@ -227,9 +255,10 @@ class TestMergeBranch: ifcgit.get_selected_branch().should_be_called().will_return("feature") ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") ifcgit.git_merge("feature").should_be_called().will_return("conflict") - ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(conflicts) + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return((conflicts, [])) ifcgit.git_merge_abort().should_be_called() ifcgit.store_merge_conflicts(conflicts).should_be_called() + ifcgit.store_merge_resolutions([]).should_be_called() op = MockOperator() subject.merge_branch(ifcgit, ifc, op) assert op.reports == [({"WARNING"}, "Merge failed — see the conflict report in the panel below")] @@ -260,6 +289,7 @@ class TestDryRunMerge: ifcgit.git_merge_no_commit("feature").should_be_called().will_return(None) ifcgit.git_merge_abort().should_be_called() ifcgit.clear_merge_conflicts().should_be_called() + ifcgit.clear_merge_resolutions().should_be_called() op = MockOperator() subject.dry_run_merge(ifcgit, ifc, op) assert op.reports == [({"INFO"}, "Merge preview: no conflicts")] @@ -271,8 +301,9 @@ class TestDryRunMerge: ifcgit.get_selected_branch().should_be_called().will_return("feature") ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") ifcgit.git_merge_no_commit("feature").should_be_called().will_return("conflict") - ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(conflicts) + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return((conflicts, [])) ifcgit.git_merge_abort().should_be_called() + ifcgit.store_merge_resolutions([]).should_be_called() ifcgit.store_merge_conflicts(conflicts).should_be_called() op = MockOperator() subject.dry_run_merge(ifcgit, ifc, op) @@ -284,13 +315,31 @@ class TestDryRunMerge: ifcgit.get_selected_branch().should_be_called().will_return("feature") ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") ifcgit.git_merge_no_commit("feature").should_be_called().will_return("conflict") - ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(None) + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return((None, [])) ifcgit.git_merge_abort().should_be_called() + ifcgit.store_merge_resolutions([]).should_be_called() ifcgit.clear_merge_conflicts().should_be_called() op = MockOperator() subject.dry_run_merge(ifcgit, ifc, op) assert op.reports == [({"INFO"}, "Merge preview: no conflicts")] + def test_conflict_preview_with_auto_resolutions(self, ifcgit, ifc): + resolutions = [{"type": "placement_auto_resolved", "entity_id": 15, "kept": "remote"}] + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge_no_commit("feature").should_be_called().will_return("conflict") + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return( + (None, resolutions) + ) + ifcgit.git_merge_abort().should_be_called() + ifcgit.store_merge_resolutions(resolutions).should_be_called() + ifcgit.clear_merge_conflicts().should_be_called() + op = MockOperator() + subject.dry_run_merge(ifcgit, ifc, op) + assert op.reports == [({"WARNING"}, "Merge preview: 1 conflict(s) would be auto-resolved, see the panel below")] + class TestEntityLog: def test_run(self, ifcgit, ifc): diff --git a/src/bonsai/test/tool/test_ifcgit.py b/src/bonsai/test/tool/test_ifcgit.py index 964bd96fd2..6b9de25bce 100644 --- a/src/bonsai/test/tool/test_ifcgit.py +++ b/src/bonsai/test/tool/test_ifcgit.py @@ -484,43 +484,99 @@ class TestStoreClearGetMergeConflicts(NewFile): assert IfcGit.get_merge_conflicts() is None +class TestStoreClearGetMergeResolutions(NewFile): + def test_round_trip(self): + resolutions = [{"type": "placement_auto_resolved", "entity_id": 15, "kept": "remote"}] + IfcGit.store_merge_resolutions(resolutions) + result = IfcGit.get_merge_resolutions() + assert result == resolutions + + def test_store_empty_list_clears(self): + IfcGit.store_merge_resolutions([{"type": "placement_auto_resolved"}]) + IfcGit.store_merge_resolutions([]) + assert IfcGit.get_merge_resolutions() is None + + def test_get_returns_none_when_empty(self): + IfcGit.clear_merge_resolutions() + assert IfcGit.get_merge_resolutions() is None + + def test_clear_removes_stored_resolutions(self): + IfcGit.store_merge_resolutions([{"type": "placement_auto_resolved"}]) + IfcGit.clear_merge_resolutions() + assert IfcGit.get_merge_resolutions() is None + + def test_get_returns_none_on_corrupt_json(self): + import bpy + + bpy.context.scene.IfcGitProperties.merge_resolutions = "not valid json {" + assert IfcGit.get_merge_resolutions() is None + + # --------------------------------------------------------------------------- # git_mergetool — report file reading # --------------------------------------------------------------------------- class TestGitMergetool: - @requires_git - def test_returns_none_when_report_file_absent(self): + @staticmethod + def _mock_repo(unmerged=False): import unittest.mock as mock + mock_repo = mock.MagicMock() + mock_repo.index.unmerged_blobs.return_value = {"model.ifc": []} if unmerged else {} + return mock_repo + + @requires_git + def test_returns_none_when_report_file_absent(self): with tempfile.TemporaryDirectory() as tmpdir: ifc_path = os.path.join(tmpdir, "model.ifc") - mock_repo = mock.MagicMock() - IfcGitRepo.repo = mock_repo - result = IfcGit.git_mergetool("ifcmerge", ifc_path) - assert result is None + IfcGitRepo.repo = self._mock_repo() + conflicts, resolutions = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert conflicts is None + assert resolutions == [] + IfcGitRepo.repo = None + + @requires_git + def test_returns_empty_conflicts_when_blobs_left_unmerged(self): + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = os.path.join(tmpdir, "model.ifc") + IfcGitRepo.repo = self._mock_repo(unmerged=True) + conflicts, resolutions = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert conflicts == [] + assert resolutions == [] IfcGitRepo.repo = None @requires_git def test_returns_none_when_report_file_empty(self): - import unittest.mock as mock - with tempfile.TemporaryDirectory() as tmpdir: ifc_path = os.path.join(tmpdir, "model.ifc") report_path = ifc_path + ".ifcmerge" open(report_path, "w").close() - mock_repo = mock.MagicMock() - IfcGitRepo.repo = mock_repo - result = IfcGit.git_mergetool("ifcmerge", ifc_path) - assert result is None + IfcGitRepo.repo = self._mock_repo() + conflicts, resolutions = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert conflicts is None + assert resolutions == [] + assert not os.path.exists(report_path) + IfcGitRepo.repo = None + + @requires_git + def test_returns_none_when_report_is_success_text(self): + # older ifcmerge writes plain "Success!" instead of a JSON report + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = os.path.join(tmpdir, "model.ifc") + report_path = ifc_path + ".ifcmerge" + with open(report_path, "w") as f: + f.write("Success!\n") + IfcGitRepo.repo = self._mock_repo() + conflicts, resolutions = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert conflicts is None + assert resolutions == [] assert not os.path.exists(report_path) IfcGitRepo.repo = None @requires_git def test_parses_conflict_report_and_deletes_file(self): import json - import unittest.mock as mock with tempfile.TemporaryDirectory() as tmpdir: ifc_path = os.path.join(tmpdir, "model.ifc") @@ -528,14 +584,51 @@ class TestGitMergetool: conflicts = [{"type": "attribute_conflict", "entity_id": 5}] with open(report_path, "w") as f: json.dump({"status": "failed", "conflicts": conflicts}, f) - mock_repo = mock.MagicMock() + mock_repo = self._mock_repo(unmerged=True) mock_repo.git.mergetool.side_effect = git.exc.GitCommandError("mergetool", 1) IfcGitRepo.repo = mock_repo - result = IfcGit.git_mergetool("ifcmerge", ifc_path) - assert result == conflicts + result_conflicts, resolutions = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert result_conflicts == conflicts + assert resolutions == [] assert not os.path.exists(report_path) IfcGitRepo.repo = None + @requires_git + def test_parses_success_report_with_auto_resolutions(self): + import json + + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = os.path.join(tmpdir, "model.ifc") + report_path = ifc_path + ".ifcmerge" + resolved = [{"type": "placement_auto_resolved", "entity_id": 15, "kept": "remote"}] + with open(report_path, "w") as f: + json.dump({"status": "success", "conflicts": [], "resolved": resolved}, f) + IfcGitRepo.repo = self._mock_repo() + conflicts, resolutions = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert conflicts is None + assert resolutions == resolved + assert not os.path.exists(report_path) + IfcGitRepo.repo = None + + @requires_git + def test_parses_failed_report_with_auto_resolutions(self): + import json + + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = os.path.join(tmpdir, "model.ifc") + report_path = ifc_path + ".ifcmerge" + conflicts = [{"type": "attribute_conflict", "entity_id": 42}] + resolved = [{"type": "placement_auto_resolved", "entity_id": 15, "kept": "remote"}] + with open(report_path, "w") as f: + json.dump({"status": "failed", "conflicts": conflicts, "resolved": resolved}, f) + mock_repo = self._mock_repo(unmerged=True) + mock_repo.git.mergetool.side_effect = git.exc.GitCommandError("mergetool", 1) + IfcGitRepo.repo = mock_repo + result_conflicts, resolutions = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert result_conflicts == conflicts + assert resolutions == resolved + IfcGitRepo.repo = None + # --------------------------------------------------------------------------- # config_ifcmerge — cmd format and update