mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 09:21:46 +00:00
Merge branch 'ifcgit-features' into v0.8.0
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user