diff --git a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py index df47b7774c..6f4fec0836 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py @@ -34,8 +34,10 @@ classes = ( operator.Fetch, operator.Merge, operator.ObjectLog, + operator.SelectConflictEntity, 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 5f01ef78ea..cc0f75577a 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"} @@ -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""" @@ -469,3 +538,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/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 6ac4aceb74..901dee31ad 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -112,15 +112,13 @@ 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="") - 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 +126,64 @@ 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.operator("ifcgit.merge", icon="SYSTEM") - row = column.row() - row.operator("ifcgit.merge", icon="EXPERIMENTAL", text="") + 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/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" diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index f14dda6cbb..289337908a 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: @@ -94,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()) @@ -143,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 1843716f58..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 @@ -155,6 +156,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) @@ -278,6 +283,10 @@ 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 @@ -285,10 +294,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 @@ -388,20 +398,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 @@ -453,11 +486,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: @@ -468,14 +518,24 @@ 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 > $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 $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") != 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 @@ -539,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