From 6c18ac96053de87e1b24881184bc4734b6f0030f Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 22:42:29 +0100 Subject: [PATCH 1/8] ifcgit: use --prioritise-local flag for ifcmerge-forward mergetool --- src/bonsai/bonsai/tool/ifcgit.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 1843716f58..91bbf1ac47 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -473,10 +473,15 @@ class IfcGit: config_writer.set_value(section, "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED") config_writer.set_value(section, "trustExitCode", True) section = 'mergetool "ifcmerge-forward"' + new_cmd = "ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED" + old_cmd = "ifcmerge $BASE $REMOTE $LOCAL $MERGED" if not config_reader.has_section(section): with IfcGitRepo.repo.config_writer() as config_writer: - config_writer.set_value(section, "cmd", "ifcmerge $BASE $REMOTE $LOCAL $MERGED") + config_writer.set_value(section, "cmd", new_cmd) config_writer.set_value(section, "trustExitCode", True) + elif config_reader.get_value(section, "cmd") == old_cmd: + with IfcGitRepo.repo.config_writer() as config_writer: + config_writer.set_value(section, "cmd", new_cmd) @classmethod def config_push(cls, repo: git.Repo) -> None: From bcc631bf5fa3a2bb4dd4ed604f7994a3f8146a25 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 22:44:56 +0100 Subject: [PATCH 2/8] ifcgit: improve colourise to find products via geometry and property changes Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/ifcgit.py | 41 +++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 91bbf1ac47..3cb9a7f227 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -388,20 +388,43 @@ class IfcGit: model = tool.Ifc.get() modified_step_ids = {"modified": set()} - for step_id in step_ids["modified"] | step_ids["added"]: - try: - entity = model.by_id(step_id) - except: - continue - if entity.is_a("IfcProductDefinitionShape"): + def collect(entity, depth=0): + if depth > 2: + return + if entity.is_a("IfcProduct"): + modified_step_ids["modified"].add(entity.id()) + elif entity.is_a("IfcProductDefinitionShape"): for product in entity.ShapeOfProduct: modified_step_ids["modified"].add(product.id()) elif entity.is_a("IfcObjectPlacement"): for product in entity.PlacesObject: modified_step_ids["modified"].add(product.id()) - elif entity.is_a("IfcTypeProduct") and entity.Types: - for related_object in entity.Types[0].RelatedObjects: - modified_step_ids["modified"].add(related_object.id()) + elif entity.is_a("IfcTypeProduct"): + for rel in entity.Types: + for obj in rel.RelatedObjects: + modified_step_ids["modified"].add(obj.id()) + elif entity.is_a("IfcShapeRepresentation"): + for prod_rep in entity.OfProductRepresentation: + for product in prod_rep.ShapeOfProduct: + modified_step_ids["modified"].add(product.id()) + elif entity.is_a("IfcRepresentationItem"): + for referencing in model.get_inverse(entity): + if referencing.is_a("IfcShapeRepresentation"): + collect(referencing, depth + 1) + elif entity.is_a("IfcPropertySet"): + for rel in entity.DefinesOccurrence: + for obj in rel.RelatedObjects: + modified_step_ids["modified"].add(obj.id()) + elif entity.is_a("IfcProperty"): + for pset in entity.PartOfPset: + collect(pset, depth + 1) + + for step_id in step_ids["modified"] | step_ids["added"]: + try: + entity = model.by_id(step_id) + except: + continue + collect(entity) return modified_step_ids From be3aef59fa0b5eed9f76ff71e547add6c6820025 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 22:45:33 +0100 Subject: [PATCH 3/8] ifcgit: move action buttons below revision list in a labelled row Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/ifcgit/operator.py | 6 +++--- src/bonsai/bonsai/bim/module/ifcgit/ui.py | 20 +++++-------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index 5f01ef78ea..32b49b7e57 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -202,7 +202,7 @@ class DeleteTag(bpy.types.Operator): class RefreshGit(bpy.types.Operator): """Refresh revision list""" - bl_label = "" + bl_label = "Refresh" bl_idname = "ifcgit.refresh" bl_options = {"REGISTER"} @@ -225,7 +225,7 @@ class RefreshGit(bpy.types.Operator): class DisplayRevision(bpy.types.Operator): """Colourise objects by selected revision""" - bl_label = "" + bl_label = "Colourise Revision" bl_idname = "ifcgit.display_revision" bl_options = {"REGISTER"} @@ -260,7 +260,7 @@ class DisplayUncommitted(bpy.types.Operator): class SwitchRevision(bpy.types.Operator): """Switches the repository to the selected revision and reloads the IFC file""" - bl_label = "" + bl_label = "Switch Revision" bl_idname = "ifcgit.switch_revision" bl_options = {"REGISTER"} diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py index 6ac4aceb74..1d03d37b74 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -113,14 +113,11 @@ class IFCGIT_PT_panel(bpy.types.Panel): else: row.label(text="Working branch: " + IfcGitData.data["active_branch_name"]) - grouped = layout.row() - column = grouped.column() - row = column.row() + row = layout.row() row.prop(props, "display_branch", text="Browse branch") row.prop(props, "ifcgit_filter", text="Filter revisions") - row = column.row() - row.template_list( + layout.template_list( "COMMIT_UL_List", "The_List", props, @@ -128,20 +125,13 @@ class IFCGIT_PT_panel(bpy.types.Panel): props, "commit_index", ) - column = grouped.column() - row = column.row() + + row = layout.row(align=True) row.operator("ifcgit.refresh", icon="FILE_REFRESH") - if not is_dirty: - - row = column.row() row.operator("ifcgit.display_revision", icon="SELECT_DIFFERENCE") - - row = column.row() row.operator("ifcgit.switch_revision", icon="CURRENT_FILE") - - row = column.row() - row.operator("ifcgit.merge", icon="EXPERIMENTAL", text="") + row.operator("ifcgit.merge", icon="SYSTEM") if not props.ifcgit_commits: return From d5b551874d9f96971aaf221888b037532a1bfbb9 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 23:16:40 +0100 Subject: [PATCH 4/8] ifcgit: pre-fill branch name when switching to a remote branch tip When a remote branch tip is checked out (resulting in detached HEAD), the new-branch name field is now pre-filled with the local equivalent of the remote branch name (generating a unique suffix if that name is already taken), so the commit button is immediately usable. See #7580 Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/ifcgit.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 3cb9a7f227..7bfda6cc57 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -476,11 +476,28 @@ class IfcGit: if item.hexsha in lookup: for branch in lookup[item.hexsha]: if branch.name == props.display_branch: + if isinstance(branch, git.RemoteReference): + # Checking out a remote branch tip goes to detached HEAD. + # Pre-fill the new branch name field with the local equivalent + # so the user isn't blocked from committing without a hint. + local_name = branch.remote_head + props.new_branch_name = cls._unique_branch_name(repo, local_name) branch.checkout() return # NOTE this is calling the git binary in a subprocess repo.git.checkout(item.hexsha) + @classmethod + def _unique_branch_name(cls, repo: git.Repo, name: str) -> str: + """Return name if unused, otherwise name-2, name-3, etc.""" + existing = {h.name for h in repo.heads} + if name not in existing: + return name + i = 2 + while f"{name}-{i}" in existing: + i += 1 + return f"{name}-{i}" + @classmethod def delete_collection(cls, blender_collection: bpy.types.Collection) -> None: for obj in blender_collection.objects: From e3cadab40656b92e80b0af88bf83cfaae54cd081 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 23:42:28 +0100 Subject: [PATCH 5/8] ifcgit: add clone widget to new project wizard (#7579) --- src/bonsai/bonsai/bim/module/project/ui.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index dfe8e44987..5e828ddd9b 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -19,6 +19,7 @@ from __future__ import annotations import os +import shutil from typing import TYPE_CHECKING import bpy @@ -384,6 +385,18 @@ class BIM_PT_new_project_wizard(Panel): row = self.layout.row() row.operator("bim.create_project") + if shutil.which("git"): + git_props = context.scene.IfcGitProperties + box = self.layout.box() + row = box.row() + row.label(text="Clone a remote Git repository") + row = box.row() + row.prop(git_props, "remote_url") + row = box.row() + row.prop(git_props, "local_folder") + row = box.row() + row.operator("ifcgit.clone_repo", icon="IMPORT") + class BIM_PT_project_library(Panel): bl_label = "Project Library" From c5210a4f8205116c9bf7f95d8927bef1b5ffea25 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 23:54:52 +0100 Subject: [PATCH 6/8] ifcgit: sync load_project post-import steps with project operator See #7578 --- src/bonsai/bonsai/tool/ifcgit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 7bfda6cc57..1a69158918 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -278,6 +278,7 @@ class IfcGit: bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument] from bonsai.bim.module.root.data import IfcClassData + import bonsai.bim.handler IfcClassData.is_loaded = False @@ -285,10 +286,11 @@ class IfcGit: settings.should_setup_viewport_camera = False ifc_importer = import_ifc.IfcImporter(settings) ifc_importer.execute() - tool.Project.load_project_pset_templates() tool.Project.load_default_thumbnails() tool.Project.set_default_context() tool.Project.set_default_modeling_dimensions() + tool.Root.reload_grid_decorator() + bonsai.bim.handler.refresh_ui_data() bpy.ops.object.select_all(action="DESELECT") @classmethod From 3a6881ca17903576746e18ee004f187a002d6544 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Fri, 3 Apr 2026 00:20:05 +0100 Subject: [PATCH 7/8] ifcgit: add rename branch button next to working branch label See #7577 Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/ifcgit/__init__.py | 1 + .../bonsai/bim/module/ifcgit/operator.py | 34 +++++++++++++++++++ src/bonsai/bonsai/bim/module/ifcgit/ui.py | 1 + src/bonsai/bonsai/core/ifcgit.py | 4 +++ src/bonsai/bonsai/tool/ifcgit.py | 4 +++ 5 files changed, 44 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py index df47b7774c..077a08b062 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py @@ -36,6 +36,7 @@ classes = ( operator.ObjectLog, operator.Push, operator.RefreshGit, + operator.RenameBranch, operator.SwitchRevision, operator.InstallGit, operator.RunGitDiff, diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index 32b49b7e57..8e86a5b18e 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -469,3 +469,37 @@ class RunGitDiff(bpy.types.Operator): def execute(self, context): core.run_git_diff(tool.IfcGit, self, self.save_to_temp) return {"FINISHED"} + + +class RenameBranch(bpy.types.Operator): + """Rename the current branch""" + + bl_label = "Rename Branch" + bl_idname = "ifcgit.rename_branch" + bl_options = {"REGISTER"} + + new_name: bpy.props.StringProperty(name="New name") # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + new_name: str + + @classmethod + def poll(cls, context): + IfcGitData.make_sure_is_loaded() + if not IfcGitData.data["repo"]: + return False + if IfcGitData.data["is_detached"]: + return False + if IfcGitData.data["is_dirty"]: + return False + return True + + def invoke(self, context, event): + self.new_name = IfcGitData.data["active_branch_name"] + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context): + repo = IfcGitData.data["repo"] + core.rename_branch(tool.IfcGit, repo, self.new_name) + refresh() + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py index 1d03d37b74..3b8bc45000 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -112,6 +112,7 @@ class IFCGIT_PT_panel(bpy.types.Panel): row.label(text="Working branch: Detached HEAD") else: row.label(text="Working branch: " + IfcGitData.data["active_branch_name"]) + row.operator("ifcgit.rename_branch", icon="GREASEPENCIL", text="") row = layout.row() row.prop(props, "display_branch", text="Browse branch") diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index f14dda6cbb..3fb8a79055 100644 --- a/src/bonsai/bonsai/core/ifcgit.py +++ b/src/bonsai/bonsai/core/ifcgit.py @@ -87,6 +87,10 @@ def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str) - ifcgit.delete_remote(repo, remote_name) +def rename_branch(ifcgit: type[tool.IfcGit], repo: git.Repo, new_name: str) -> None: + ifcgit.rename_branch(repo, new_name) + + def push(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, operator: bpy.types.Operator) -> None: error_message = ifcgit.push(repo, remote_name, ifcgit.get_active_branch_name()) if error_message: diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 1a69158918..74cef0f522 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -155,6 +155,10 @@ class IfcGit: if tag_name in repo.tags: repo.delete_tag(tag_name) + @classmethod + def rename_branch(cls, repo: git.Repo, new_name: str) -> None: + repo.active_branch.rename(new_name) + @classmethod def add_remote(cls, repo: git.Repo, remote_name: str, remote_url: str) -> None: repo.create_remote(name=remote_name, url=remote_url) From ca6e9504968cd1097df4bccd7de12a49ba43faa7 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Fri, 3 Apr 2026 13:27:29 +0100 Subject: [PATCH 8/8] ifcgit: conflict report panel and dry-run merge preview Parse ifcmerge JSON output and display a per-conflict breakdown in the panel when merge fails. Ctrl+click on the Merge button previews conflicts without committing. Add SelectConflictEntity operator to select and frame the conflicting object in the 3D viewport. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/ifcgit/__init__.py | 1 + .../bonsai/bim/module/ifcgit/operator.py | 73 +++++++++- src/bonsai/bonsai/bim/module/ifcgit/prop.py | 6 + src/bonsai/bonsai/bim/module/ifcgit/ui.py | 51 +++++++ src/bonsai/bonsai/core/ifcgit.py | 48 ++++++- src/bonsai/bonsai/core/tool.py | 6 +- src/bonsai/bonsai/tool/ifcgit.py | 73 ++++++++-- src/bonsai/test/core/test_ifcgit.py | 61 ++++++++- src/bonsai/test/tool/test_ifcgit.py | 125 ++++++++++++++++++ 9 files changed, 427 insertions(+), 17 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py index 077a08b062..6f4fec0836 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py @@ -34,6 +34,7 @@ classes = ( operator.Fetch, operator.Merge, operator.ObjectLog, + operator.SelectConflictEntity, operator.Push, operator.RefreshGit, operator.RenameBranch, diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index 8e86a5b18e..cc0f75577a 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -278,7 +278,7 @@ class SwitchRevision(bpy.types.Operator): class Merge(bpy.types.Operator): - """Merges the selected branch into working branch""" + """Merges the selected branch into working branch.\nCtrl+click to preview without merging""" bl_label = "Merge this branch" bl_idname = "ifcgit.merge" @@ -292,8 +292,14 @@ class Merge(bpy.types.Operator): return True return False - def execute(self, context): + def invoke(self, context, event): + if event.ctrl: + core.dry_run_merge(tool.IfcGit, tool.Ifc, self) + refresh() + return {"FINISHED"} + return self.execute(context) + def execute(self, context): if core.merge_branch(tool.IfcGit, tool.Ifc, self) is not False: refresh() return {"FINISHED"} @@ -301,6 +307,69 @@ class Merge(bpy.types.Operator): return {"CANCELLED"} +class SelectConflictEntity(bpy.types.Operator): + """Select the conflicting entity in the viewport""" + + bl_label = "Select Conflict Entity" + bl_idname = "ifcgit.select_conflict_entity" + bl_options = {"REGISTER"} + + step_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + step_id: int + + def execute(self, context): + model = tool.Ifc.get() + if not model: + return {"CANCELLED"} + + try: + entity = model.by_id(self.step_id) + except Exception: + self.report({"WARNING"}, f"Entity #{self.step_id} not found (may have been deleted locally)") + return {"CANCELLED"} + + obj = tool.Ifc.get_object(entity) + if obj is None: + # Walk inverse references up to 5 hops to find nearest entity with a Blender object + visited = {entity.id()} + queue = [entity] + for _ in range(5): + next_queue = [] + for ent in queue: + for inv in model.get_inverse(ent): + if inv.id() in visited: + continue + visited.add(inv.id()) + obj = tool.Ifc.get_object(inv) + if obj is not None: + break + next_queue.append(inv) + if obj is not None: + break + if obj is not None: + break + queue = next_queue + + if obj is None: + self.report({"INFO"}, f"No viewport representation found for #{self.step_id} ({entity.is_a()})") + return {"CANCELLED"} + + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + context.view_layer.objects.active = obj + for area in context.screen.areas: + if area.type == "VIEW_3D": + region = next((r for r in area.regions if r.type == "WINDOW"), None) + if region: + with context.temp_override(area=area, region=region): + bpy.ops.view3d.view_selected() + break + + return {"FINISHED"} + + class Push(bpy.types.Operator): """Pushes the working branch to selected remote""" diff --git a/src/bonsai/bonsai/bim/module/ifcgit/prop.py b/src/bonsai/bonsai/bim/module/ifcgit/prop.py index 865cb4eacf..494da26f35 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/prop.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/prop.py @@ -139,6 +139,11 @@ class IfcGitProperties(PropertyGroup): ], update=update_revlist, ) + merge_conflicts: StringProperty( + name="Merge Conflicts", + description="JSON report from last failed merge attempt", + default="", + ) if TYPE_CHECKING: ifcgit_commits: bpy.types.bpy_prop_collection_idprop[IfcGitListItem] @@ -153,3 +158,4 @@ class IfcGitProperties(PropertyGroup): display_branch: str select_remote: str ifcgit_filter: Literal["all", "tagged", "relevant"] + merge_conflicts: str diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py index 3b8bc45000..901dee31ad 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -134,6 +134,57 @@ class IFCGIT_PT_panel(bpy.types.Panel): row.operator("ifcgit.switch_revision", icon="CURRENT_FILE") row.operator("ifcgit.merge", icon="SYSTEM") + conflicts = tool.IfcGit.get_merge_conflicts() + if conflicts is not None: + box = layout.box() + box.alert = True + row = box.row() + row.label( + text=f"Merge failed \u2014 {len(conflicts)} conflict(s)", + icon="ERROR", + ) + for conflict in conflicts: + col = box.column(align=True) + conflict_type = conflict.get("type", "") + entity_id = conflict.get("entity_id", "?") + local_id = conflict.get("original_local_id") + + if conflict_type == "attribute_conflict": + entity_class = conflict.get("entity_class", "Entity") + attr_idx = conflict.get("attribute_index", "?") + desc = f"#{entity_id} {entity_class}: attribute {attr_idx} conflict" + elif conflict_type == "entity_deleted_and_modified": + entity_class = conflict.get("entity_class", "Entity") + desc = f"#{entity_id} {entity_class}: " + conflict.get("message", "deleted/modified conflict") + elif conflict_type == "class_changed": + desc = ( + f"#{entity_id}: class changed " + + conflict.get("base_class", "?") + + " \u2192 " + + conflict.get("modified_class", "?") + ) + elif conflict_type == "required_entity_deleted": + desc = f"#{entity_id}: " + conflict.get("message", "required entity deleted") + else: + desc = f"#{entity_id}: {conflict_type}" + + row = col.row(align=True) + row.label(text=desc) + if local_id: + op = row.operator( + "ifcgit.select_conflict_entity", + text="", + icon="RESTRICT_SELECT_OFF", + ) + op.step_id = local_id + + if conflict_type == "attribute_conflict": + sub = col.column(align=True) + sub.scale_y = 0.75 + 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 not props.ifcgit_commits: return diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index 3fb8a79055..289337908a 100644 --- a/src/bonsai/bonsai/core/ifcgit.py +++ b/src/bonsai/bonsai/core/ifcgit.py @@ -98,6 +98,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() if ifcgit.repo_has_commits(): ifcgit.refresh_revision_list(ifc.get_path()) @@ -147,19 +148,60 @@ def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.t operator.report({"ERROR"}, "Unknown IFC Merge failure") return False elif merge_result == "conflict": - error = ifcgit.git_mergetool(mergetool) - if error: + conflicts = ifcgit.git_mergetool(mergetool, path_ifc) + if conflicts is not None: ifcgit.git_merge_abort() - operator.report({"ERROR"}, "IFC Merge failed:" + error) + ifcgit.store_merge_conflicts(conflicts) + operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below") return False ifcgit.commit_merge(path_ifc) + ifcgit.clear_merge_conflicts() ifcgit.set_display_branch() + ifcgit.git_checkout(path_ifc) ifcgit.load_project(path_ifc) ifcgit.refresh_revision_list(path_ifc) ifcgit.decolourise() +def dry_run_merge(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> None: + path_ifc = ifc.get_path() + ifcgit.config_ifcmerge() + + branch_name = ifcgit.get_selected_branch() + if branch_name is None: + return + + mergetool = ifcgit.get_merge_tool(branch_name) + merge_result = ifcgit.git_merge_no_commit(branch_name) + + if merge_result == "error": + try: + ifcgit.git_merge_abort() + except Exception: + pass + operator.report({"ERROR"}, "Unknown IFC Merge failure") + return + + if merge_result == "conflict": + conflicts = ifcgit.git_mergetool(mergetool, path_ifc) + ifcgit.git_merge_abort() + 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") + else: + # Clean merge or already up to date — abort the pending merge state if any + try: + ifcgit.git_merge_abort() + except Exception: + pass + ifcgit.clear_merge_conflicts() + operator.report({"INFO"}, "Merge preview: no conflicts") + + def entity_log(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], step_id: int, operator: bpy.types.Operator) -> None: path_ifc = ifc.get_path() log_text = ifcgit.entity_log(path_ifc, step_id) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 910ed79627..e6a60c9deb 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -559,7 +559,11 @@ class IfcGit: def get_selected_branch(cls): pass def git_merge(cls, branch_name): pass def git_merge_abort(cls): pass - def git_mergetool(cls, mergetool): pass + def git_merge_no_commit(cls, branch_name): pass + def git_mergetool(cls, mergetool, path_ifc): pass + def store_merge_conflicts(cls, conflicts): pass + def clear_merge_conflicts(cls): pass + def get_merge_conflicts(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 74cef0f522..ae7176eb6f 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -18,6 +18,7 @@ from __future__ import annotations +import json import logging import os import re @@ -282,8 +283,11 @@ class IfcGit: bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument] from bonsai.bim.module.root.data import IfcClassData + from bonsai.bim.module.model.data import AuthoringData import bonsai.bim.handler + AuthoringData.type_thumbnails = {} + IfcClassData.is_loaded = False settings = import_ifc.IfcImportSettings.factory(bpy.context, path_ifc, logging.getLogger("ImportIFC")) @@ -514,20 +518,25 @@ class IfcGit: def config_ifcmerge(cls) -> None: config_reader = IfcGitRepo.repo.config_reader() section = 'mergetool "ifcmerge"' + new_cmd = "ifcmerge $BASE $LOCAL $REMOTE $MERGED > $MERGED.ifcmerge" if not config_reader.has_section(section): with IfcGitRepo.repo.config_writer() as config_writer: - config_writer.set_value(section, "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED") + config_writer.set_value(section, "cmd", new_cmd) + config_writer.set_value(section, "trustExitCode", True) + elif config_reader.get_value(section, "cmd") != new_cmd: + with IfcGitRepo.repo.config_writer() as config_writer: + config_writer.set_value(section, "cmd", new_cmd) config_writer.set_value(section, "trustExitCode", True) section = 'mergetool "ifcmerge-forward"' - new_cmd = "ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED" - old_cmd = "ifcmerge $BASE $REMOTE $LOCAL $MERGED" + new_cmd = "ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED > $MERGED.ifcmerge" if not config_reader.has_section(section): with IfcGitRepo.repo.config_writer() as config_writer: config_writer.set_value(section, "cmd", new_cmd) config_writer.set_value(section, "trustExitCode", True) - elif config_reader.get_value(section, "cmd") == old_cmd: + elif config_reader.get_value(section, "cmd") != new_cmd: with IfcGitRepo.repo.config_writer() as config_writer: config_writer.set_value(section, "cmd", new_cmd) + config_writer.set_value(section, "trustExitCode", True) @classmethod def config_push(cls, repo: git.Repo) -> None: @@ -590,14 +599,62 @@ class IfcGit: return "error" @classmethod - def git_mergetool(cls, mergetool: str) -> Union[str, None]: - """Run ifcmerge tool. Returns None on success, error message string on failure.""" + def git_merge_no_commit(cls, branch_name: str) -> Union[str, None]: + """Attempt a git merge without committing (always leaves a merge state to abort). + Returns None on clean merge, 'conflict' on conflict, or 'error' on unknown failure.""" repo = IfcGitRepo.repo + branch = repo.branches[branch_name] + try: + repo.git.merge(branch, no_commit=True, no_ff=True) + return None + except git.exc.GitCommandError: + return "conflict" + except git.exc.GitError: + 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.""" + repo = IfcGitRepo.repo + report_path = path_ifc + ".ifcmerge" try: repo.git.mergetool(tool=mergetool) + except git.exc.GitCommandError: + pass + + conflicts = None + if os.path.exists(report_path): + try: + with open(report_path) as f: + content = f.read().strip() + if content: + data = json.loads(content) + conflicts = data.get("conflicts", []) + except (json.JSONDecodeError, OSError): + pass + try: + os.remove(report_path) + except OSError: + pass + return conflicts + + @classmethod + def store_merge_conflicts(cls, conflicts: list) -> None: + cls.get_ifcgit_props().merge_conflicts = json.dumps(conflicts) + + @classmethod + def clear_merge_conflicts(cls) -> None: + cls.get_ifcgit_props().merge_conflicts = "" + + @classmethod + def get_merge_conflicts(cls) -> Union[list, None]: + raw = cls.get_ifcgit_props().merge_conflicts + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: return None - except git.exc.GitCommandError as exc: - return re.sub("( stdout: '|')", "", exc.stdout) @classmethod def git_merge_abort(cls) -> None: diff --git a/src/bonsai/test/core/test_ifcgit.py b/src/bonsai/test/core/test_ifcgit.py index b4d80985b2..539e4a082c 100644 --- a/src/bonsai/test/core/test_ifcgit.py +++ b/src/bonsai/test/core/test_ifcgit.py @@ -132,12 +132,14 @@ class TestPush: class TestRefreshRevisionList: def test_refreshes_when_repo_has_heads(self, ifcgit, ifc): + ifcgit.clear_merge_conflicts().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() subject.refresh_revision_list(ifcgit, ifc) def test_skips_when_repo_has_no_heads(self, ifcgit, ifc): + ifcgit.clear_merge_conflicts().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 @@ -194,7 +196,9 @@ 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(None) + ifcgit.clear_merge_conflicts().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() @@ -206,25 +210,29 @@ 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").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.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() subject.merge_branch(ifcgit, ifc, operator=None) 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") 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").should_be_called().will_return("merge error") + 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() op = MockOperator() subject.merge_branch(ifcgit, ifc, op) - assert op.reports == [({"ERROR"}, "IFC Merge failed:merge error")] + assert op.reports == [({"WARNING"}, "Merge failed — see the conflict report in the panel below")] def test_unknown_merge_error(self, ifcgit, ifc): ifc.get_path().should_be_called().will_return("path/to/model.ifc") @@ -237,6 +245,53 @@ class TestMergeBranch: assert op.reports == [({"ERROR"}, "Unknown IFC Merge failure")] +class TestDryRunMerge: + def test_no_branch_at_selected_commit(self, ifcgit, ifc): + 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(None) + subject.dry_run_merge(ifcgit, ifc, operator=None) + + def test_clean_merge_preview(self, ifcgit, ifc): + 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(None) + ifcgit.git_merge_abort().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_shows_report(self, ifcgit, ifc): + conflicts = [{"type": "attribute_conflict", "entity_id": 42}] + 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(conflicts) + ifcgit.git_merge_abort().should_be_called() + ifcgit.store_merge_conflicts(conflicts).should_be_called() + op = MockOperator() + subject.dry_run_merge(ifcgit, ifc, op) + assert op.reports == [({"WARNING"}, "Merge preview: conflicts found — see the panel below")] + + def test_conflict_preview_mergetool_succeeds(self, ifcgit, ifc): + 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) + ifcgit.git_merge_abort().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")] + + class TestEntityLog: def test_run(self, ifcgit, ifc): ifc.get_path().should_be_called().will_return("path/to/model.ifc") diff --git a/src/bonsai/test/tool/test_ifcgit.py b/src/bonsai/test/tool/test_ifcgit.py index 5cd051aa5f..964bd96fd2 100644 --- a/src/bonsai/test/tool/test_ifcgit.py +++ b/src/bonsai/test/tool/test_ifcgit.py @@ -454,3 +454,128 @@ class TestIfcDiffIds(NewFile): result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path) assert 1 in result["modified"] assert 2 in result["modified"] + + +# --------------------------------------------------------------------------- +# Merge conflict report — store / clear / get +# --------------------------------------------------------------------------- + + +class TestStoreClearGetMergeConflicts(NewFile): + def test_round_trip(self): + conflicts = [{"type": "attribute_conflict", "entity_id": 42}] + IfcGit.store_merge_conflicts(conflicts) + result = IfcGit.get_merge_conflicts() + assert result == conflicts + + def test_get_returns_none_when_empty(self): + IfcGit.clear_merge_conflicts() + assert IfcGit.get_merge_conflicts() is None + + def test_clear_removes_stored_conflicts(self): + IfcGit.store_merge_conflicts([{"type": "class_changed"}]) + IfcGit.clear_merge_conflicts() + assert IfcGit.get_merge_conflicts() is None + + def test_get_returns_none_on_corrupt_json(self): + import bpy + + bpy.context.scene.IfcGitProperties.merge_conflicts = "not valid json {" + assert IfcGit.get_merge_conflicts() is None + + +# --------------------------------------------------------------------------- +# git_mergetool — report file reading +# --------------------------------------------------------------------------- + + +class TestGitMergetool: + @requires_git + def test_returns_none_when_report_file_absent(self): + import unittest.mock as mock + + 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 = 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 + 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") + report_path = ifc_path + ".ifcmerge" + 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.git.mergetool.side_effect = git.exc.GitCommandError("mergetool", 1) + IfcGitRepo.repo = mock_repo + result = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert result == conflicts + assert not os.path.exists(report_path) + IfcGitRepo.repo = None + + +# --------------------------------------------------------------------------- +# config_ifcmerge — cmd format and update +# --------------------------------------------------------------------------- + + +class TestConfigIfcmerge: + @requires_git + def test_writes_redirect_cmd_on_first_call(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGitRepo.repo = repo + IfcGit.config_ifcmerge() + reader = repo.config_reader() + cmd = reader.get_value('mergetool "ifcmerge"', "cmd") + assert "> $MERGED.ifcmerge" in cmd + IfcGitRepo.repo = None + + @requires_git + def test_updates_cmd_missing_redirect(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGitRepo.repo = repo + with repo.config_writer() as w: + w.set_value('mergetool "ifcmerge"', "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED") + w.set_value('mergetool "ifcmerge"', "trustExitCode", True) + IfcGit.config_ifcmerge() + reader = repo.config_reader() + cmd = reader.get_value('mergetool "ifcmerge"', "cmd") + assert "> $MERGED.ifcmerge" in cmd + IfcGitRepo.repo = None + + @requires_git + def test_forward_tool_writes_redirect_cmd(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGitRepo.repo = repo + IfcGit.config_ifcmerge() + reader = repo.config_reader() + cmd = reader.get_value('mergetool "ifcmerge-forward"', "cmd") + assert "--prioritise-local" in cmd + assert "> $MERGED.ifcmerge" in cmd + IfcGitRepo.repo = None