From 117f858236d0fb2af084b87d590974e262c57de3 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Tue, 21 Jul 2026 12:43:03 +0300 Subject: [PATCH] ifcgit: report conflicts auto-resolved by ifcmerge in the merge panel ifcmerge resolves object-placement conflicts (the same object moved in both branches) by silently keeping one branch's position. The user gets no indication that their move was discarded (#6885). Newer ifcmerge can list these auto-resolutions in its JSON report. Parse that report on success as well as on failure, store the resolutions, and show them in the Git panel: which objects were moved in both branches, which branch's position was kept, with a select-in-viewport button per object. The merge operator also reports a warning with the count, and the Ctrl+click merge preview shows the affected objects before anything is committed. With an older ifcmerge that writes no report on success, behaviour is unchanged. Resolutions are stored after the display-branch switch, because that switch triggers a revision list refresh which clears stored reports. Cap the conflict and resolution lists at 50 rows each, since reports can contain thousands of entries. Also fix two mergetool tests that broke when the unmerged_blobs guard was added: a bare MagicMock is truthy, so git_mergetool returned [] where the tests expected None. Fixes #6885 Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/ifcgit/prop.py | 6 + src/bonsai/bonsai/bim/module/ifcgit/ui.py | 39 +++++- src/bonsai/bonsai/core/ifcgit.py | 24 +++- src/bonsai/bonsai/core/tool.py | 3 + src/bonsai/bonsai/tool/ifcgit.py | 35 +++++- src/bonsai/test/core/test_ifcgit.py | 57 ++++++++- src/bonsai/test/tool/test_ifcgit.py | 125 +++++++++++++++++--- 7 files changed, 261 insertions(+), 28 deletions(-) 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