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.
This commit is contained in:
Petru Conduraru
2026-07-21 12:43:03 +03:00
parent e52e5e2e58
commit 117f858236
7 changed files with 261 additions and 28 deletions
@@ -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
+38 -1
View File
@@ -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
+21 -3
View File
@@ -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")
+3
View File
@@ -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
+31 -4
View File
@@ -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)
+53 -4
View File
@@ -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):
+109 -16
View File
@@ -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