mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-17 14:02:27 +00:00
Merge branch 'ifcgit-features' into v0.8.0
This commit is contained in:
@@ -34,8 +34,10 @@ classes = (
|
|||||||
operator.Fetch,
|
operator.Fetch,
|
||||||
operator.Merge,
|
operator.Merge,
|
||||||
operator.ObjectLog,
|
operator.ObjectLog,
|
||||||
|
operator.SelectConflictEntity,
|
||||||
operator.Push,
|
operator.Push,
|
||||||
operator.RefreshGit,
|
operator.RefreshGit,
|
||||||
|
operator.RenameBranch,
|
||||||
operator.SwitchRevision,
|
operator.SwitchRevision,
|
||||||
operator.InstallGit,
|
operator.InstallGit,
|
||||||
operator.RunGitDiff,
|
operator.RunGitDiff,
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ class DeleteTag(bpy.types.Operator):
|
|||||||
class RefreshGit(bpy.types.Operator):
|
class RefreshGit(bpy.types.Operator):
|
||||||
"""Refresh revision list"""
|
"""Refresh revision list"""
|
||||||
|
|
||||||
bl_label = ""
|
bl_label = "Refresh"
|
||||||
bl_idname = "ifcgit.refresh"
|
bl_idname = "ifcgit.refresh"
|
||||||
bl_options = {"REGISTER"}
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ class RefreshGit(bpy.types.Operator):
|
|||||||
class DisplayRevision(bpy.types.Operator):
|
class DisplayRevision(bpy.types.Operator):
|
||||||
"""Colourise objects by selected revision"""
|
"""Colourise objects by selected revision"""
|
||||||
|
|
||||||
bl_label = ""
|
bl_label = "Colourise Revision"
|
||||||
bl_idname = "ifcgit.display_revision"
|
bl_idname = "ifcgit.display_revision"
|
||||||
bl_options = {"REGISTER"}
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
@@ -260,7 +260,7 @@ class DisplayUncommitted(bpy.types.Operator):
|
|||||||
class SwitchRevision(bpy.types.Operator):
|
class SwitchRevision(bpy.types.Operator):
|
||||||
"""Switches the repository to the selected revision and reloads the IFC file"""
|
"""Switches the repository to the selected revision and reloads the IFC file"""
|
||||||
|
|
||||||
bl_label = ""
|
bl_label = "Switch Revision"
|
||||||
bl_idname = "ifcgit.switch_revision"
|
bl_idname = "ifcgit.switch_revision"
|
||||||
bl_options = {"REGISTER"}
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ class SwitchRevision(bpy.types.Operator):
|
|||||||
|
|
||||||
|
|
||||||
class Merge(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_label = "Merge this branch"
|
||||||
bl_idname = "ifcgit.merge"
|
bl_idname = "ifcgit.merge"
|
||||||
@@ -292,8 +292,14 @@ class Merge(bpy.types.Operator):
|
|||||||
return True
|
return True
|
||||||
return False
|
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:
|
if core.merge_branch(tool.IfcGit, tool.Ifc, self) is not False:
|
||||||
refresh()
|
refresh()
|
||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
@@ -301,6 +307,69 @@ class Merge(bpy.types.Operator):
|
|||||||
return {"CANCELLED"}
|
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):
|
class Push(bpy.types.Operator):
|
||||||
"""Pushes the working branch to selected remote"""
|
"""Pushes the working branch to selected remote"""
|
||||||
|
|
||||||
@@ -469,3 +538,37 @@ class RunGitDiff(bpy.types.Operator):
|
|||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
core.run_git_diff(tool.IfcGit, self, self.save_to_temp)
|
core.run_git_diff(tool.IfcGit, self, self.save_to_temp)
|
||||||
return {"FINISHED"}
|
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"}
|
||||||
|
|||||||
@@ -139,6 +139,11 @@ class IfcGitProperties(PropertyGroup):
|
|||||||
],
|
],
|
||||||
update=update_revlist,
|
update=update_revlist,
|
||||||
)
|
)
|
||||||
|
merge_conflicts: StringProperty(
|
||||||
|
name="Merge Conflicts",
|
||||||
|
description="JSON report from last failed merge attempt",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
ifcgit_commits: bpy.types.bpy_prop_collection_idprop[IfcGitListItem]
|
ifcgit_commits: bpy.types.bpy_prop_collection_idprop[IfcGitListItem]
|
||||||
@@ -153,3 +158,4 @@ class IfcGitProperties(PropertyGroup):
|
|||||||
display_branch: str
|
display_branch: str
|
||||||
select_remote: str
|
select_remote: str
|
||||||
ifcgit_filter: Literal["all", "tagged", "relevant"]
|
ifcgit_filter: Literal["all", "tagged", "relevant"]
|
||||||
|
merge_conflicts: str
|
||||||
|
|||||||
@@ -112,15 +112,13 @@ class IFCGIT_PT_panel(bpy.types.Panel):
|
|||||||
row.label(text="Working branch: Detached HEAD")
|
row.label(text="Working branch: Detached HEAD")
|
||||||
else:
|
else:
|
||||||
row.label(text="Working branch: " + IfcGitData.data["active_branch_name"])
|
row.label(text="Working branch: " + IfcGitData.data["active_branch_name"])
|
||||||
|
row.operator("ifcgit.rename_branch", icon="GREASEPENCIL", text="")
|
||||||
|
|
||||||
grouped = layout.row()
|
row = layout.row()
|
||||||
column = grouped.column()
|
|
||||||
row = column.row()
|
|
||||||
row.prop(props, "display_branch", text="Browse branch")
|
row.prop(props, "display_branch", text="Browse branch")
|
||||||
row.prop(props, "ifcgit_filter", text="Filter revisions")
|
row.prop(props, "ifcgit_filter", text="Filter revisions")
|
||||||
|
|
||||||
row = column.row()
|
layout.template_list(
|
||||||
row.template_list(
|
|
||||||
"COMMIT_UL_List",
|
"COMMIT_UL_List",
|
||||||
"The_List",
|
"The_List",
|
||||||
props,
|
props,
|
||||||
@@ -128,20 +126,64 @@ class IFCGIT_PT_panel(bpy.types.Panel):
|
|||||||
props,
|
props,
|
||||||
"commit_index",
|
"commit_index",
|
||||||
)
|
)
|
||||||
column = grouped.column()
|
|
||||||
row = column.row()
|
row = layout.row(align=True)
|
||||||
row.operator("ifcgit.refresh", icon="FILE_REFRESH")
|
row.operator("ifcgit.refresh", icon="FILE_REFRESH")
|
||||||
|
|
||||||
if not is_dirty:
|
if not is_dirty:
|
||||||
|
|
||||||
row = column.row()
|
|
||||||
row.operator("ifcgit.display_revision", icon="SELECT_DIFFERENCE")
|
row.operator("ifcgit.display_revision", icon="SELECT_DIFFERENCE")
|
||||||
|
|
||||||
row = column.row()
|
|
||||||
row.operator("ifcgit.switch_revision", icon="CURRENT_FILE")
|
row.operator("ifcgit.switch_revision", icon="CURRENT_FILE")
|
||||||
|
row.operator("ifcgit.merge", icon="SYSTEM")
|
||||||
|
|
||||||
row = column.row()
|
conflicts = tool.IfcGit.get_merge_conflicts()
|
||||||
row.operator("ifcgit.merge", icon="EXPERIMENTAL", text="")
|
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:
|
if not props.ifcgit_commits:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
@@ -384,6 +385,18 @@ class BIM_PT_new_project_wizard(Panel):
|
|||||||
row = self.layout.row()
|
row = self.layout.row()
|
||||||
row.operator("bim.create_project")
|
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):
|
class BIM_PT_project_library(Panel):
|
||||||
bl_label = "Project Library"
|
bl_label = "Project Library"
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str) -
|
|||||||
ifcgit.delete_remote(repo, remote_name)
|
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:
|
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())
|
error_message = ifcgit.push(repo, remote_name, ifcgit.get_active_branch_name())
|
||||||
if error_message:
|
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:
|
def refresh_revision_list(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None:
|
||||||
|
ifcgit.clear_merge_conflicts()
|
||||||
if ifcgit.repo_has_commits():
|
if ifcgit.repo_has_commits():
|
||||||
ifcgit.refresh_revision_list(ifc.get_path())
|
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")
|
operator.report({"ERROR"}, "Unknown IFC Merge failure")
|
||||||
return False
|
return False
|
||||||
elif merge_result == "conflict":
|
elif merge_result == "conflict":
|
||||||
error = ifcgit.git_mergetool(mergetool)
|
conflicts = ifcgit.git_mergetool(mergetool, path_ifc)
|
||||||
if error:
|
if conflicts is not None:
|
||||||
ifcgit.git_merge_abort()
|
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
|
return False
|
||||||
ifcgit.commit_merge(path_ifc)
|
ifcgit.commit_merge(path_ifc)
|
||||||
|
|
||||||
|
ifcgit.clear_merge_conflicts()
|
||||||
ifcgit.set_display_branch()
|
ifcgit.set_display_branch()
|
||||||
|
ifcgit.git_checkout(path_ifc)
|
||||||
ifcgit.load_project(path_ifc)
|
ifcgit.load_project(path_ifc)
|
||||||
ifcgit.refresh_revision_list(path_ifc)
|
ifcgit.refresh_revision_list(path_ifc)
|
||||||
ifcgit.decolourise()
|
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:
|
def entity_log(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], step_id: int, operator: bpy.types.Operator) -> None:
|
||||||
path_ifc = ifc.get_path()
|
path_ifc = ifc.get_path()
|
||||||
log_text = ifcgit.entity_log(path_ifc, step_id)
|
log_text = ifcgit.entity_log(path_ifc, step_id)
|
||||||
|
|||||||
@@ -559,7 +559,11 @@ class IfcGit:
|
|||||||
def get_selected_branch(cls): pass
|
def get_selected_branch(cls): pass
|
||||||
def git_merge(cls, branch_name): pass
|
def git_merge(cls, branch_name): pass
|
||||||
def git_merge_abort(cls): 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 set_display_branch(cls): pass
|
||||||
def get_active_branch_name(cls): pass
|
def get_active_branch_name(cls): pass
|
||||||
def get_ifcgit_props(cls): pass
|
def get_ifcgit_props(cls): pass
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -155,6 +156,10 @@ class IfcGit:
|
|||||||
if tag_name in repo.tags:
|
if tag_name in repo.tags:
|
||||||
repo.delete_tag(tag_name)
|
repo.delete_tag(tag_name)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def rename_branch(cls, repo: git.Repo, new_name: str) -> None:
|
||||||
|
repo.active_branch.rename(new_name)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def add_remote(cls, repo: git.Repo, remote_name: str, remote_url: str) -> None:
|
def add_remote(cls, repo: git.Repo, remote_name: str, remote_url: str) -> None:
|
||||||
repo.create_remote(name=remote_name, url=remote_url)
|
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]
|
bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument]
|
||||||
|
|
||||||
from bonsai.bim.module.root.data import IfcClassData
|
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
|
IfcClassData.is_loaded = False
|
||||||
|
|
||||||
@@ -285,10 +294,11 @@ class IfcGit:
|
|||||||
settings.should_setup_viewport_camera = False
|
settings.should_setup_viewport_camera = False
|
||||||
ifc_importer = import_ifc.IfcImporter(settings)
|
ifc_importer = import_ifc.IfcImporter(settings)
|
||||||
ifc_importer.execute()
|
ifc_importer.execute()
|
||||||
tool.Project.load_project_pset_templates()
|
|
||||||
tool.Project.load_default_thumbnails()
|
tool.Project.load_default_thumbnails()
|
||||||
tool.Project.set_default_context()
|
tool.Project.set_default_context()
|
||||||
tool.Project.set_default_modeling_dimensions()
|
tool.Project.set_default_modeling_dimensions()
|
||||||
|
tool.Root.reload_grid_decorator()
|
||||||
|
bonsai.bim.handler.refresh_ui_data()
|
||||||
bpy.ops.object.select_all(action="DESELECT")
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -388,20 +398,43 @@ class IfcGit:
|
|||||||
model = tool.Ifc.get()
|
model = tool.Ifc.get()
|
||||||
modified_step_ids = {"modified": set()}
|
modified_step_ids = {"modified": set()}
|
||||||
|
|
||||||
for step_id in step_ids["modified"] | step_ids["added"]:
|
def collect(entity, depth=0):
|
||||||
try:
|
if depth > 2:
|
||||||
entity = model.by_id(step_id)
|
return
|
||||||
except:
|
if entity.is_a("IfcProduct"):
|
||||||
continue
|
modified_step_ids["modified"].add(entity.id())
|
||||||
if entity.is_a("IfcProductDefinitionShape"):
|
elif entity.is_a("IfcProductDefinitionShape"):
|
||||||
for product in entity.ShapeOfProduct:
|
for product in entity.ShapeOfProduct:
|
||||||
modified_step_ids["modified"].add(product.id())
|
modified_step_ids["modified"].add(product.id())
|
||||||
elif entity.is_a("IfcObjectPlacement"):
|
elif entity.is_a("IfcObjectPlacement"):
|
||||||
for product in entity.PlacesObject:
|
for product in entity.PlacesObject:
|
||||||
modified_step_ids["modified"].add(product.id())
|
modified_step_ids["modified"].add(product.id())
|
||||||
elif entity.is_a("IfcTypeProduct") and entity.Types:
|
elif entity.is_a("IfcTypeProduct"):
|
||||||
for related_object in entity.Types[0].RelatedObjects:
|
for rel in entity.Types:
|
||||||
modified_step_ids["modified"].add(related_object.id())
|
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
|
return modified_step_ids
|
||||||
|
|
||||||
@@ -453,11 +486,28 @@ class IfcGit:
|
|||||||
if item.hexsha in lookup:
|
if item.hexsha in lookup:
|
||||||
for branch in lookup[item.hexsha]:
|
for branch in lookup[item.hexsha]:
|
||||||
if branch.name == props.display_branch:
|
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()
|
branch.checkout()
|
||||||
return
|
return
|
||||||
# NOTE this is calling the git binary in a subprocess
|
# NOTE this is calling the git binary in a subprocess
|
||||||
repo.git.checkout(item.hexsha)
|
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
|
@classmethod
|
||||||
def delete_collection(cls, blender_collection: bpy.types.Collection) -> None:
|
def delete_collection(cls, blender_collection: bpy.types.Collection) -> None:
|
||||||
for obj in blender_collection.objects:
|
for obj in blender_collection.objects:
|
||||||
@@ -468,14 +518,24 @@ class IfcGit:
|
|||||||
def config_ifcmerge(cls) -> None:
|
def config_ifcmerge(cls) -> None:
|
||||||
config_reader = IfcGitRepo.repo.config_reader()
|
config_reader = IfcGitRepo.repo.config_reader()
|
||||||
section = 'mergetool "ifcmerge"'
|
section = 'mergetool "ifcmerge"'
|
||||||
|
new_cmd = "ifcmerge $BASE $LOCAL $REMOTE $MERGED > $MERGED.ifcmerge"
|
||||||
if not config_reader.has_section(section):
|
if not config_reader.has_section(section):
|
||||||
with IfcGitRepo.repo.config_writer() as config_writer:
|
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)
|
config_writer.set_value(section, "trustExitCode", True)
|
||||||
section = 'mergetool "ifcmerge-forward"'
|
section = 'mergetool "ifcmerge-forward"'
|
||||||
|
new_cmd = "ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED > $MERGED.ifcmerge"
|
||||||
if not config_reader.has_section(section):
|
if not config_reader.has_section(section):
|
||||||
with IfcGitRepo.repo.config_writer() as config_writer:
|
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)
|
config_writer.set_value(section, "trustExitCode", True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -539,14 +599,62 @@ class IfcGit:
|
|||||||
return "error"
|
return "error"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def git_mergetool(cls, mergetool: str) -> Union[str, None]:
|
def git_merge_no_commit(cls, branch_name: str) -> Union[str, None]:
|
||||||
"""Run ifcmerge tool. Returns None on success, error message string on failure."""
|
"""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
|
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:
|
try:
|
||||||
repo.git.mergetool(tool=mergetool)
|
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
|
return None
|
||||||
except git.exc.GitCommandError as exc:
|
|
||||||
return re.sub("( stdout: '|')", "", exc.stdout)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def git_merge_abort(cls) -> None:
|
def git_merge_abort(cls) -> None:
|
||||||
|
|||||||
@@ -132,12 +132,14 @@ class TestPush:
|
|||||||
|
|
||||||
class TestRefreshRevisionList:
|
class TestRefreshRevisionList:
|
||||||
def test_refreshes_when_repo_has_heads(self, ifcgit, ifc):
|
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)
|
ifcgit.repo_has_commits().should_be_called().will_return(True)
|
||||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||||
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
||||||
subject.refresh_revision_list(ifcgit, ifc)
|
subject.refresh_revision_list(ifcgit, ifc)
|
||||||
|
|
||||||
def test_skips_when_repo_has_no_heads(self, 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)
|
ifcgit.repo_has_commits().should_be_called().will_return(False)
|
||||||
subject.refresh_revision_list(ifcgit, ifc)
|
subject.refresh_revision_list(ifcgit, ifc)
|
||||||
# nothing else should be called — Prophecy will verify
|
# 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_selected_branch().should_be_called().will_return("feature")
|
||||||
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||||
ifcgit.git_merge("feature").should_be_called().will_return(None)
|
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.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.load_project("path/to/model.ifc").should_be_called()
|
||||||
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
||||||
ifcgit.decolourise().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_selected_branch().should_be_called().will_return("feature")
|
||||||
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||||
ifcgit.git_merge("feature").should_be_called().will_return("conflict")
|
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.commit_merge("path/to/model.ifc").should_be_called()
|
||||||
|
ifcgit.clear_merge_conflicts().should_be_called()
|
||||||
ifcgit.set_display_branch().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.load_project("path/to/model.ifc").should_be_called()
|
||||||
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
||||||
ifcgit.decolourise().should_be_called()
|
ifcgit.decolourise().should_be_called()
|
||||||
subject.merge_branch(ifcgit, ifc, operator=None)
|
subject.merge_branch(ifcgit, ifc, operator=None)
|
||||||
|
|
||||||
def test_conflict_mergetool_failure(self, ifcgit, ifc):
|
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")
|
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||||
ifcgit.config_ifcmerge().should_be_called()
|
ifcgit.config_ifcmerge().should_be_called()
|
||||||
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
||||||
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||||
ifcgit.git_merge("feature").should_be_called().will_return("conflict")
|
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.git_merge_abort().should_be_called()
|
||||||
|
ifcgit.store_merge_conflicts(conflicts).should_be_called()
|
||||||
op = MockOperator()
|
op = MockOperator()
|
||||||
subject.merge_branch(ifcgit, ifc, op)
|
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):
|
def test_unknown_merge_error(self, ifcgit, ifc):
|
||||||
ifc.get_path().should_be_called().will_return("path/to/model.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")]
|
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:
|
class TestEntityLog:
|
||||||
def test_run(self, ifcgit, ifc):
|
def test_run(self, ifcgit, ifc):
|
||||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||||
|
|||||||
@@ -454,3 +454,128 @@ class TestIfcDiffIds(NewFile):
|
|||||||
result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
|
result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
|
||||||
assert 1 in result["modified"]
|
assert 1 in result["modified"]
|
||||||
assert 2 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
|
||||||
|
|||||||
Reference in New Issue
Block a user