mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 09:21:46 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user