Refactor ifcgit, fix UI bugs and performance

Move all business logic into bonsai core and tool. Performance fixes to
minimise file IO, various minor bug fixes and tests.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Bruno Postle
2026-04-01 23:37:32 +01:00
parent fb81c88a5f
commit 5e784e4175
10 changed files with 1053 additions and 232 deletions
+64 -79
View File
@@ -21,65 +21,71 @@ class IfcGitData:
@classmethod @classmethod
def load(cls): def load(cls):
repo = None
if bool(tool.Ifc.get()):
path_ifc = tool.Ifc.get_path()
if os.path.isfile(path_ifc):
repo = tool.IfcGit.repo_from_path(path_ifc)
cls.data = { cls.data = {
"repo": cls.repo(), "repo": repo,
"remotes": cls.remotes(), "remotes": repo.remotes if repo else None,
"branch_names": cls.branch_names(), "branch_names": cls.branch_names(repo),
"remote_names": cls.remote_names(), "tag_names": cls.tag_names(repo),
"remote_urls": cls.remote_urls(), "remote_names": cls.remote_names(repo),
"remote_urls": {r.name: r.url for r in repo.remotes} if repo else {},
"path_ifc": cls.path_ifc(), "path_ifc": cls.path_ifc(),
"branches_by_hexsha": cls.branches_by_hexsha(), "branches_by_hexsha": cls.branches_by_hexsha(),
"tags_by_hexsha": cls.tags_by_hexsha(), "tags_by_hexsha": cls.tags_by_hexsha(),
"name_ifc": cls.name_ifc(), "name_ifc": cls.name_ifc(repo),
"dir_name": cls.dir_name(), "dir_name": cls.dir_name(),
"base_name": cls.base_name(), "base_name": cls.base_name(),
"working_dir": cls.working_dir(), "working_dir": repo.working_dir if repo else None,
"untracked_files": cls.untracked_files(), "ifc_is_untracked": cls.ifc_is_untracked(repo),
"is_detached": cls.is_detached(), "is_detached": repo.head.is_detached if repo else None,
"active_branch_name": cls.active_branch_name(), "active_branch_name": repo.active_branch.name if repo and not repo.head.is_detached else None,
"is_dirty": cls.is_dirty(), "is_dirty": cls.is_dirty(repo),
"commit": cls.commit(), "current_revision": cls.current_revision(repo),
"current_revision": cls.current_revision(),
"git_exe": cls.git_exe(), "git_exe": cls.git_exe(),
"ifcmerge_exe": cls.ifcmerge_exe(), "ifcmerge_exe": cls.ifcmerge_exe(),
} }
cls.is_loaded = True cls.is_loaded = True
@classmethod @classmethod
def repo(cls): def branch_names(cls, repo):
if bool(tool.Ifc.get()): if not repo or not repo.heads:
path_ifc = tool.Ifc.get_path() return []
if os.path.isfile(path_ifc): names = sorted([b.name for b in repo.branches])
return tool.IfcGit.repo_from_path(path_ifc) if "main" in names:
return None names.remove("main")
names = ["main"] + names
if repo.remotes:
for remote in repo.remotes:
for ref in remote.refs:
names.append(ref.name)
return names
@classmethod @classmethod
def remotes(cls): def tag_names(cls, repo):
if cls.repo(): if not repo:
return cls.repo().remotes return []
return None return [t.name for t in repo.tags]
@classmethod @classmethod
def branch_names(cls): def remote_names(cls, repo):
return [] if not repo:
return []
@classmethod names = sorted([r.name for r in repo.remotes])
def remote_names(cls): if "origin" in names:
return [] names.remove("origin")
names = ["origin"] + names
@classmethod return names
def remote_urls(cls):
result = {}
if cls.repo():
for remote in cls.repo().remotes:
result[remote.name] = remote.url
return result
@classmethod @classmethod
def path_ifc(cls): def path_ifc(cls):
path_ifc = tool.Ifc.get_path() path_ifc = tool.Ifc.get_path()
if os.path.isfile(path_ifc): if os.path.isfile(path_ifc):
return tool.Ifc.get_path() return path_ifc
return None return None
@classmethod @classmethod
@@ -88,7 +94,8 @@ class IfcGitData:
if tool.IfcGitRepo.repo.branches: if tool.IfcGitRepo.repo.branches:
return tool.IfcGit.branches_by_hexsha(tool.IfcGitRepo.repo) return tool.IfcGit.branches_by_hexsha(tool.IfcGitRepo.repo)
except AttributeError: except AttributeError:
return {} pass
return {}
@classmethod @classmethod
def tags_by_hexsha(cls): def tags_by_hexsha(cls):
@@ -97,12 +104,11 @@ class IfcGitData:
return {} return {}
@classmethod @classmethod
def name_ifc(cls): def name_ifc(cls, repo):
if bool(tool.Ifc.get()): if bool(tool.Ifc.get()) and repo:
path_ifc = tool.Ifc.get_path() path_ifc = tool.Ifc.get_path()
if tool.IfcGitRepo.repo and os.path.isfile(path_ifc): if os.path.isfile(path_ifc):
working_dir = tool.IfcGitRepo.repo.working_dir return os.path.relpath(path_ifc, repo.working_dir)
return os.path.relpath(path_ifc, working_dir)
return None return None
@classmethod @classmethod
@@ -122,49 +128,28 @@ class IfcGitData:
return None return None
@classmethod @classmethod
def working_dir(cls): def ifc_is_untracked(cls, repo):
if cls.repo(): """Return True if the IFC file exists in the repo but has not been added to git."""
return cls.repo().working_dir if not repo:
return False
path_ifc = tool.Ifc.get_path()
if not os.path.isfile(path_ifc):
return False
return not bool(repo.git.ls_files(path_ifc))
@classmethod @classmethod
def untracked_files(cls): def is_dirty(cls, repo):
if cls.repo(): if repo and cls.git_exe():
return cls.repo().untracked_files
return []
@classmethod
def is_detached(cls):
if cls.repo():
return cls.repo().head.is_detached
@classmethod
def active_branch_name(cls):
if cls.repo() and not cls.is_detached():
return cls.repo().active_branch.name
@classmethod
def is_dirty(cls):
if cls.repo() and cls.git_exe():
path_ifc = tool.Ifc.get_path() path_ifc = tool.Ifc.get_path()
if os.path.isfile(path_ifc): if os.path.isfile(path_ifc):
return cls.repo().is_dirty(path=path_ifc) return repo.is_dirty(path=path_ifc)
return False return False
@classmethod @classmethod
def commit(cls): def current_revision(cls, repo):
props = tool.IfcGit.get_ifcgit_props() props = tool.IfcGit.get_ifcgit_props()
if cls.repo() and len(props.ifcgit_commits) > 0: if repo and repo.head.is_valid() and len(props.ifcgit_commits) > 0:
item = props.ifcgit_commits[props.commit_index] return repo.commit()
try:
return cls.repo().commit(rev=item.hexsha)
except ValueError:
return
@classmethod
def current_revision(cls):
props = tool.IfcGit.get_ifcgit_props()
if cls.repo() and cls.repo().head.is_valid() and len(props.ifcgit_commits) > 0:
return tool.IfcGitRepo.repo.commit()
@classmethod @classmethod
def git_exe(cls): def git_exe(cls):
+46 -22
View File
@@ -120,11 +120,11 @@ class CommitChanges(bpy.types.Operator):
if props.commit_message == "": if props.commit_message == "":
return False return False
if repo: if repo:
if props.new_branch_name in [branch.name for branch in repo.branches]: if props.new_branch_name in IfcGitData.data["branch_names"]:
cls.poll_message_set("Branch already exists!") cls.poll_message_set("Branch already exists!")
return False return False
elif not tool.IfcGit.is_valid_ref_format(props.new_branch_name): elif not tool.IfcGit.is_valid_ref_format(props.new_branch_name):
if repo.head.is_detached: if IfcGitData.data["is_detached"]:
cls.poll_message_set("Branch name is invalid or empty!") cls.poll_message_set("Branch name is invalid or empty!")
return False return False
elif props.new_branch_name != "": elif props.new_branch_name != "":
@@ -134,10 +134,17 @@ class CommitChanges(bpy.types.Operator):
def execute(self, context): def execute(self, context):
repo = IfcGitData.data["repo"] props = tool.IfcGit.get_ifcgit_props()
core.commit_changes(tool.IfcGit, tool.Ifc, repo) commit_message = props.commit_message
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) new_branch_name = props.new_branch_name
core.commit_changes(tool.IfcGit, tool.Ifc, commit_message, new_branch_name)
props.new_branch_name = ""
props.commit_message = ""
core.refresh_revision_list(tool.IfcGit, tool.Ifc)
refresh() refresh()
IfcGitData.load()
if new_branch_name:
props.display_branch = new_branch_name
return {"FINISHED"} return {"FINISHED"}
@@ -157,7 +164,7 @@ class AddTag(bpy.types.Operator):
repo = IfcGitData.data["repo"] repo = IfcGitData.data["repo"]
if repo and ( if repo and (
not tool.IfcGit.is_valid_ref_format(props.new_tag_name) not tool.IfcGit.is_valid_ref_format(props.new_tag_name)
or props.new_tag_name in [tag.name for tag in repo.tags] or props.new_tag_name in IfcGitData.data["tag_names"]
): ):
return False return False
return True return True
@@ -165,8 +172,12 @@ class AddTag(bpy.types.Operator):
def execute(self, context): def execute(self, context):
repo = IfcGitData.data["repo"] repo = IfcGitData.data["repo"]
core.add_tag(tool.IfcGit, repo) props = tool.IfcGit.get_ifcgit_props()
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) item = props.ifcgit_commits[props.commit_index]
core.add_tag(tool.IfcGit, repo, item.hexsha, props.new_tag_name, props.new_tag_message)
props.new_tag_name = ""
props.new_tag_message = ""
core.refresh_revision_list(tool.IfcGit, tool.Ifc)
refresh() refresh()
return {"FINISHED"} return {"FINISHED"}
@@ -183,7 +194,7 @@ class DeleteTag(bpy.types.Operator):
repo = IfcGitData.data["repo"] repo = IfcGitData.data["repo"]
core.delete_tag(tool.IfcGit, repo, self.tag_name) core.delete_tag(tool.IfcGit, repo, self.tag_name)
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) core.refresh_revision_list(tool.IfcGit, tool.Ifc)
refresh() refresh()
return {"FINISHED"} return {"FINISHED"}
@@ -205,8 +216,7 @@ class RefreshGit(bpy.types.Operator):
def execute(self, context): def execute(self, context):
repo = IfcGitData.data["repo"] core.refresh_revision_list(tool.IfcGit, tool.Ifc)
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
refresh() refresh()
tool.IfcGit.decolourise() tool.IfcGit.decolourise()
return {"FINISHED"} return {"FINISHED"}
@@ -284,7 +294,7 @@ class Merge(bpy.types.Operator):
def execute(self, context): def execute(self, context):
if core.merge_branch(tool.IfcGit, tool.Ifc, self): if core.merge_branch(tool.IfcGit, tool.Ifc, self) is not False:
refresh() refresh()
return {"FINISHED"} return {"FINISHED"}
else: else:
@@ -314,9 +324,9 @@ class Fetch(bpy.types.Operator):
def execute(self, context): def execute(self, context):
props = tool.IfcGit.get_ifcgit_props() props = tool.IfcGit.get_ifcgit_props()
repo = IfcGitData.data["repo"] core.fetch(tool.IfcGit, props.select_remote)
remote = repo.remotes[props.select_remote] core.refresh_revision_list(tool.IfcGit, tool.Ifc)
remote.fetch() refresh()
return {"FINISHED"} return {"FINISHED"}
@@ -336,7 +346,7 @@ class AddRemote(bpy.types.Operator):
not repo not repo
or not tool.IfcGit.is_valid_ref_format(props.remote_name) or not tool.IfcGit.is_valid_ref_format(props.remote_name)
or not props.remote_url or not props.remote_url
or props.remote_name in [remote.name for remote in repo.remotes] or props.remote_name in IfcGitData.data["remote_names"]
): ):
return False return False
return True return True
@@ -344,8 +354,11 @@ class AddRemote(bpy.types.Operator):
def execute(self, context): def execute(self, context):
repo = IfcGitData.data["repo"] repo = IfcGitData.data["repo"]
core.add_remote(tool.IfcGit, repo) props = tool.IfcGit.get_ifcgit_props()
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) core.add_remote(tool.IfcGit, repo, props.remote_name, props.remote_url)
props.remote_name = ""
props.remote_url = ""
core.refresh_revision_list(tool.IfcGit, tool.Ifc)
refresh() refresh()
return {"FINISHED"} return {"FINISHED"}
@@ -360,8 +373,19 @@ class DeleteRemote(bpy.types.Operator):
def execute(self, context): def execute(self, context):
repo = IfcGitData.data["repo"] repo = IfcGitData.data["repo"]
core.delete_remote(tool.IfcGit, repo) props = tool.IfcGit.get_ifcgit_props()
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) remote_name = props.select_remote
if props.display_branch.startswith(remote_name + "/"):
active = IfcGitData.data["active_branch_name"]
if active:
props.display_branch = active
else:
local_branches = [b for b in IfcGitData.data["branch_names"] if "/" not in b]
if local_branches:
props.display_branch = local_branches[0]
core.delete_remote(tool.IfcGit, repo, remote_name)
tool.IfcGit.select_first_remote()
core.refresh_revision_list(tool.IfcGit, tool.Ifc)
refresh() refresh()
return {"FINISHED"} return {"FINISHED"}
@@ -375,8 +399,8 @@ class ObjectLog(bpy.types.Operator):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
if not (obj := context.active_object): if not (obj := context.active_object) or not obj.select_get():
cls.poll_message_set("No Active Object") cls.poll_message_set("No selected object")
elif not tool.Blender.get_ifc_definition_id(obj): elif not tool.Blender.get_ifc_definition_id(obj):
cls.poll_message_set("Active Object doesn't have an IFC definition") cls.poll_message_set("Active Object doesn't have an IFC definition")
else: else:
+7 -19
View File
@@ -17,28 +17,14 @@ from bonsai.bim.module.ifcgit.data import IfcGitData
def git_branches(self: "IfcGitProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS: def git_branches(self: "IfcGitProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
# NOTE "Python must keep a reference to the strings returned by # NOTE "Python must keep a reference to the strings returned by
# the callback or Blender will misbehave or even crash" # the callback or Blender will misbehave or even crash"
IfcGitData.data["branch_names"] = sorted([branch.name for branch in IfcGitData.data["repo"].heads]) # Branch list (local + remote, main first) is computed once in IfcGitData.load()
IfcGitData.make_sure_is_loaded()
if "main" in IfcGitData.data["branch_names"]: return [(name, name, name) for name in IfcGitData.data["branch_names"]]
IfcGitData.data["branch_names"].remove("main")
IfcGitData.data["branch_names"] = ["main"] + IfcGitData.data["branch_names"]
if IfcGitData.data["remotes"]:
for remote in IfcGitData.data["remotes"]:
for remote_branch in remote.refs:
IfcGitData.data["branch_names"].append(remote_branch.name)
return [(myname, myname, myname) for myname in IfcGitData.data["branch_names"]]
def git_remotes(self: "IfcGitProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS: def git_remotes(self: "IfcGitProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
IfcGitData.data["remote_names"] = sorted([remote.name for remote in IfcGitData.data["remotes"]]) IfcGitData.make_sure_is_loaded()
return [(name, name, name) for name in IfcGitData.data["remote_names"]]
if "origin" in IfcGitData.data["remote_names"]:
IfcGitData.data["remote_names"].remove("origin")
IfcGitData.data["remote_names"] = ["origin"] + IfcGitData.data["remote_names"]
return [(myname, myname, myname) for myname in IfcGitData.data["remote_names"]]
def update_revlist(self: "IfcGitProperties", context: bpy.types.Context) -> None: def update_revlist(self: "IfcGitProperties", context: bpy.types.Context) -> None:
@@ -90,6 +76,7 @@ class IfcGitListItem(PropertyGroup):
name="Commit Message", name="Commit Message",
default="", default="",
) )
committed_date: IntProperty(name="Committed Date", default=0)
tags: CollectionProperty(type=IfcGitTag, name="List of revision tags") tags: CollectionProperty(type=IfcGitTag, name="List of revision tags")
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -98,6 +85,7 @@ class IfcGitListItem(PropertyGroup):
author_name: str author_name: str
author_email: str author_email: str
message: str message: str
committed_date: int
tags: bpy.types.bpy_prop_collection_idprop[IfcGitTag] tags: bpy.types.bpy_prop_collection_idprop[IfcGitTag]
+6 -12
View File
@@ -52,7 +52,7 @@ class IFCGIT_PT_panel(bpy.types.Panel):
if IfcGitData.data["repo"] and os.path.exists(IfcGitData.data["repo"].git_dir): if IfcGitData.data["repo"] and os.path.exists(IfcGitData.data["repo"].git_dir):
name_ifc = IfcGitData.data["name_ifc"] name_ifc = IfcGitData.data["name_ifc"]
row.label(text=IfcGitData.data["working_dir"], icon="SYSTEM") row.label(text=IfcGitData.data["working_dir"], icon="SYSTEM")
if name_ifc in IfcGitData.data["untracked_files"]: if IfcGitData.data["ifc_is_untracked"]:
row.operator( row.operator(
"ifcgit.addfile", "ifcgit.addfile",
text="Add '" + name_ifc + "' to repository", text="Add '" + name_ifc + "' to repository",
@@ -216,13 +216,7 @@ class COMMIT_UL_List(bpy.types.UIList):
): ):
current_revision = IfcGitData.data["current_revision"] current_revision = IfcGitData.data["current_revision"]
current_hexsha = current_revision.hexsha if current_revision else None
# TODO Figure how this "item" can be acesse in "data.py"
# so it's possible to move the ".commit"
try:
commit = IfcGitData.data["repo"].commit(rev=item.hexsha)
except ValueError:
return
lookup = IfcGitData.data["branches_by_hexsha"] lookup = IfcGitData.data["branches_by_hexsha"]
refs = "" refs = ""
@@ -236,11 +230,11 @@ class COMMIT_UL_List(bpy.types.UIList):
for tag in lookup[item.hexsha]: for tag in lookup[item.hexsha]:
refs += "{" + tag.name + "} " refs += "{" + tag.name + "} "
if commit == current_revision: if item.hexsha == current_hexsha:
layout.label(text="[HEAD] " + refs + commit.message.split("\n")[0], icon="DECORATE_KEYFRAME") layout.label(text="[HEAD] " + refs + item.message.split("\n")[0], icon="DECORATE_KEYFRAME")
else: else:
layout.label(text=refs + commit.message.split("\n")[0], icon="DECORATE_ANIMATE") layout.label(text=refs + item.message.split("\n")[0], icon="DECORATE_ANIMATE")
layout.label(text=time.strftime("%c", time.localtime(commit.committed_date))) layout.label(text=time.strftime("%c", time.localtime(item.committed_date)))
def draw_filter(self, context, layout): def draw_filter(self, context, layout):
+46 -17
View File
@@ -56,42 +56,45 @@ def discard_uncommitted(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None:
ifcgit.load_project(path_ifc) ifcgit.load_project(path_ifc)
def commit_changes(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], repo: git.Repo) -> None: def commit_changes(
ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], commit_message: str, new_branch_name: str = ""
) -> None:
"""Commit and create new branches as required""" """Commit and create new branches as required"""
path_ifc = ifc.get_path() path_ifc = ifc.get_path()
if repo.head.is_detached: if ifcgit.is_head_detached():
ifcgit.git_commit(path_ifc) ifcgit.git_commit(path_ifc, commit_message)
ifcgit.create_new_branch() ifcgit.create_new_branch(new_branch_name)
else: else:
ifcgit.checkout_new_branch(path_ifc) if new_branch_name:
ifcgit.git_commit(path_ifc) ifcgit.checkout_new_branch(path_ifc, new_branch_name)
ifcgit.git_commit(path_ifc, commit_message)
def add_tag(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None: def add_tag(ifcgit: type[tool.IfcGit], repo: git.Repo, hexsha: str, tag_name: str, tag_message: str = "") -> None:
ifcgit.add_tag(repo) ifcgit.add_tag(repo, hexsha, tag_name, tag_message)
def delete_tag(ifcgit: type[tool.IfcGit], repo: git.Repo, tag_name: git.TagReference) -> None: def delete_tag(ifcgit: type[tool.IfcGit], repo: git.Repo, tag_name: git.TagReference) -> None:
ifcgit.delete_tag(repo, tag_name) ifcgit.delete_tag(repo, tag_name)
def add_remote(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None: def add_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, remote_url: str) -> None:
ifcgit.add_remote(repo) ifcgit.add_remote(repo, remote_name, remote_url)
def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None: def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str) -> None:
ifcgit.delete_remote(repo) ifcgit.delete_remote(repo, remote_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, repo.active_branch.name) error_message = ifcgit.push(repo, remote_name, ifcgit.get_active_branch_name())
if error_message: if error_message:
operator.report({"ERROR"}, error_message) operator.report({"ERROR"}, error_message)
def refresh_revision_list(ifcgit: type[tool.IfcGit], repo: git.Repo, ifc: type[tool.Ifc]) -> None: def refresh_revision_list(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None:
if repo.heads: if ifcgit.repo_has_commits():
ifcgit.refresh_revision_list(ifc.get_path()) ifcgit.refresh_revision_list(ifc.get_path())
@@ -125,10 +128,32 @@ def switch_revision(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None:
ifcgit.decolourise() ifcgit.decolourise()
def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> None: def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> bool | None:
path_ifc = ifc.get_path() path_ifc = ifc.get_path()
ifcgit.config_ifcmerge() ifcgit.config_ifcmerge()
ifcgit.execute_merge(path_ifc, operator)
branch_name = ifcgit.get_selected_branch()
if branch_name is None:
return
mergetool = ifcgit.get_merge_tool(branch_name)
merge_result = ifcgit.git_merge(branch_name)
if merge_result == "error":
operator.report({"ERROR"}, "Unknown IFC Merge failure")
return False
elif merge_result == "conflict":
error = ifcgit.git_mergetool(mergetool)
if error:
ifcgit.git_merge_abort()
operator.report({"ERROR"}, "IFC Merge failed:" + error)
return False
ifcgit.commit_merge(path_ifc)
ifcgit.set_display_branch()
ifcgit.load_project(path_ifc)
ifcgit.refresh_revision_list(path_ifc)
ifcgit.decolourise()
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:
@@ -145,5 +170,9 @@ def install_git(ifcgit: type[tool.IfcGit], operator: bpy.types.Operator) -> None
print("install_git() not implemented") print("install_git() not implemented")
def fetch(ifcgit: type[tool.IfcGit], remote_name: str) -> None:
ifcgit.fetch(remote_name)
def run_git_diff(ifcgit: type[tool.IfcGit], operator: bpy.types.Operator, save_to_temp: bool) -> None: def run_git_diff(ifcgit: type[tool.IfcGit], operator: bpy.types.Operator, save_to_temp: bool) -> None:
ifcgit.run_git_diff(operator, save_to_temp) ifcgit.run_git_diff(operator, save_to_temp)
+50
View File
@@ -535,6 +535,56 @@ class Ifc:
def get_all_element_occurrences(cls, element): pass def get_all_element_occurrences(cls, element): pass
@interface
class IfcGit:
def add_file_to_repo(cls, repo, path_file): pass
def add_remote(cls, repo, remote_name, remote_url): pass
def add_tag(cls, repo, hexsha, tag_name, tag_message): pass
def branches_by_hexsha(cls, repo): pass
def checkout_new_branch(cls, path_file, branch_name): pass
def clear_commits_list(cls): pass
def clone_repo(cls, remote_url, local_folder): pass
def colourise(cls, step_ids): pass
def config_ifcmerge(cls): pass
def create_new_branch(cls, branch_name): pass
def decolourise(cls): pass
def delete_remote(cls, repo, remote_name): pass
def delete_tag(cls, repo, tag_name): pass
def dos2unix(cls, path_file): pass
def commit_merge(cls, path_ifc): pass
def entity_log(cls, path_ifc, step_id): pass
def fetch(cls, remote_name): pass
def get_commits_list(cls, path_ifc, lookup): pass
def get_merge_tool(cls, branch_name): pass
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 set_display_branch(cls): pass
def get_active_branch_name(cls): pass
def get_ifcgit_props(cls): pass
def get_modified_step_ids(cls, step_ids): pass
def get_path_dir(cls, path_ifc): pass
def get_revisions_step_ids(cls): pass
def is_head_detached(cls): pass
def repo_has_commits(cls): pass
def git_checkout(cls, path_file): pass
def git_commit(cls, path_file, commit_message): pass
def ifc_diff_ids(cls, repo, hash_a, hash_b, path_ifc): pass
def init_repo(cls, path_dir): pass
def install_git_windows(cls, operator): pass
def is_valid_ref_format(cls, string): pass
def load_anyifc(cls, repo): pass
def load_project(cls, path_ifc): pass
def push(cls, repo, remote_name, branch_name): pass
def refresh_revision_list(cls, path_ifc): pass
def repo_from_path(cls, path): pass
def run_git_diff(cls, operator, save_to_temp): pass
def switch_to_revision_item(cls): pass
def tags_by_hexsha(cls, repo): pass
def update_step_ids(cls, step_ids, modified_step_ids): pass
@interface @interface
class Layer: class Layer:
pass pass
+97 -83
View File
@@ -24,7 +24,7 @@ import re
import subprocess import subprocess
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Union from typing import TYPE_CHECKING, Any, Union
import bpy import bpy
@@ -128,39 +128,27 @@ class IfcGit:
cls.dos2unix(path_file) cls.dos2unix(path_file)
repo.index.add(os.path.normpath(path_file)) repo.index.add(os.path.normpath(path_file))
repo.index.commit(message="Added " + os.path.relpath(path_file, repo.working_dir)) repo.index.commit(message="Added " + os.path.relpath(path_file, repo.working_dir))
bpy.ops.ifcgit.refresh()
@classmethod @classmethod
def git_checkout(cls, path_file: str) -> None: def git_checkout(cls, path_file: str) -> None:
IfcGitRepo.repo.git.checkout(path_file) IfcGitRepo.repo.git.checkout(path_file)
@classmethod @classmethod
def checkout_new_branch(cls, path_file: str) -> None: def checkout_new_branch(cls, path_file: str, branch_name: str) -> None:
"""Create a branch and move uncommitted changes to this branch""" """Create a branch and move uncommitted changes to this branch"""
props = cls.get_ifcgit_props() IfcGitRepo.repo.git.checkout(b=branch_name)
if props.new_branch_name:
IfcGitRepo.repo.git.checkout(b=props.new_branch_name)
props.display_branch = props.new_branch_name
props.new_branch_name = ""
bpy.ops.ifcgit.refresh()
@classmethod @classmethod
def git_commit(cls, path_file: str) -> None: def git_commit(cls, path_file: str, commit_message: str) -> None:
props = cls.get_ifcgit_props()
repo = IfcGitRepo.repo repo = IfcGitRepo.repo
if os.name == "nt": if os.name == "nt":
cls.dos2unix(path_file) cls.dos2unix(path_file)
repo.index.add(os.path.normpath(path_file)) repo.index.add(os.path.normpath(path_file))
repo.index.commit(message=props.commit_message) repo.index.commit(message=commit_message)
props.commit_message = ""
@classmethod @classmethod
def add_tag(cls, repo: git.Repo) -> None: def add_tag(cls, repo: git.Repo, hexsha: str, tag_name: str, tag_message: str = "") -> None:
props = cls.get_ifcgit_props() repo.create_tag(tag_name, ref=hexsha, message=tag_message)
item = props.ifcgit_commits[props.commit_index]
repo.create_tag(props.new_tag_name, ref=item.hexsha, message=props.new_tag_message)
props.new_tag_name = ""
props.new_tag_message = ""
@classmethod @classmethod
def delete_tag(cls, repo: git.Repo, tag_name: git.TagReference) -> None: def delete_tag(cls, repo: git.Repo, tag_name: git.TagReference) -> None:
@@ -168,20 +156,13 @@ class IfcGit:
repo.delete_tag(tag_name) repo.delete_tag(tag_name)
@classmethod @classmethod
def add_remote(cls, repo: git.Repo) -> None: def add_remote(cls, repo: git.Repo, remote_name: str, remote_url: str) -> None:
props = cls.get_ifcgit_props() repo.create_remote(name=remote_name, url=remote_url)
repo.create_remote(name=props.remote_name, url=props.remote_url)
props.remote_name = ""
props.remote_url = ""
@classmethod @classmethod
def delete_remote(cls, repo: git.Repo) -> None: def delete_remote(cls, repo: git.Repo, remote_name: str) -> None:
props = cls.get_ifcgit_props()
remote_name = props.select_remote
if remote_name in repo.remotes: if remote_name in repo.remotes:
repo.delete_remote(remote_name) repo.delete_remote(remote_name)
if repo.remotes:
props.select_remote = repo.remotes[0].name
@classmethod @classmethod
def push(cls, repo: git.Repo, remote_name: str, branch_name: str) -> Union[str, None]: def push(cls, repo: git.Repo, remote_name: str, branch_name: str) -> Union[str, None]:
@@ -193,16 +174,25 @@ class IfcGit:
return exc.stderr return exc.stderr
@classmethod @classmethod
def create_new_branch(cls) -> None: def is_head_detached(cls) -> bool:
"""Convert a detached HEAD into a branch""" return bool(IfcGitRepo.repo.head.is_detached)
props = cls.get_ifcgit_props()
repo = IfcGitRepo.repo
new_branch = repo.create_head(props.new_branch_name)
new_branch.checkout()
props.display_branch = props.new_branch_name
props.new_branch_name = ""
bpy.ops.ifcgit.refresh() @classmethod
def repo_has_commits(cls) -> bool:
if IfcGitRepo.repo:
return bool(IfcGitRepo.repo.heads)
return False
@classmethod
def get_active_branch_name(cls) -> str:
return IfcGitRepo.repo.active_branch.name
@classmethod
def create_new_branch(cls, branch_name: str) -> None:
"""Convert a detached HEAD into a branch"""
repo = IfcGitRepo.repo
new_branch = repo.create_head(branch_name)
new_branch.checkout()
@classmethod @classmethod
def clear_commits_list(cls) -> None: def clear_commits_list(cls) -> None:
@@ -243,6 +233,7 @@ class IfcGit:
list_item.message = commit.message list_item.message = commit.message
list_item.author_name = commit.author.name list_item.author_name = commit.author.name
list_item.author_email = commit.author.email list_item.author_email = commit.author.email
list_item.committed_date = int(commit.committed_date)
if commit in commits_relevant: if commit in commits_relevant:
list_item.relevant = True list_item.relevant = True
if commit.hexsha in lookup: if commit.hexsha in lookup:
@@ -286,6 +277,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
IfcClassData.is_loaded = False
settings = import_ifc.IfcImportSettings.factory(bpy.context, path_ifc, logging.getLogger("ImportIFC")) settings = import_ifc.IfcImportSettings.factory(bpy.context, path_ifc, logging.getLogger("ImportIFC"))
settings.should_setup_viewport_camera = False settings.should_setup_viewport_camera = False
ifc_importer = import_ifc.IfcImporter(settings) ifc_importer = import_ifc.IfcImporter(settings)
@@ -469,15 +464,6 @@ class IfcGit:
bpy.data.objects.remove(obj, do_unlink=True) bpy.data.objects.remove(obj, do_unlink=True)
bpy.data.collections.remove(blender_collection) bpy.data.collections.remove(blender_collection)
@classmethod
def is_valid_branch_name(cls, new_branch_name: str):
"""Check if a branch name is valid and doesn't conflict with existing branches"""
if not cls.is_valid_ref_format(new_branch_name):
return False
if new_branch_name in [branch.name for branch in IfcGitRepo.repo.branches]:
return False
return True
@classmethod @classmethod
def config_ifcmerge(cls) -> None: def config_ifcmerge(cls) -> None:
config_reader = IfcGitRepo.repo.config_reader() config_reader = IfcGitRepo.repo.config_reader()
@@ -519,49 +505,65 @@ class IfcGit:
output.write(line + b"\n") output.write(line + b"\n")
@classmethod @classmethod
def execute_merge(cls, path_ifc: str, operator: bpy.types.Operator) -> Union[None, Literal[False]]: def get_selected_branch(cls) -> Union[str, None]:
"""Return the name of the branch at the selected commit matching display_branch, or None."""
props = cls.get_ifcgit_props() props = cls.get_ifcgit_props()
repo = IfcGitRepo.repo repo = IfcGitRepo.repo
item = props.ifcgit_commits[props.commit_index] item = props.ifcgit_commits[props.commit_index]
lookup = cls.branches_by_hexsha(repo) lookup = cls.branches_by_hexsha(repo)
if item.hexsha in lookup: if item.hexsha not in lookup:
for branch in lookup[item.hexsha]: return None
if branch.name == props.display_branch: for branch in lookup[item.hexsha]:
# this is a branch! if branch.name == props.display_branch:
if re.match("^(origin/)?(HEAD|main|master)$", branch.name): return branch.name
# preserve remote IDs in origin/main or main return None
mergetool = "ifcmerge"
else:
# rewrite remote IDs
mergetool = "ifcmerge-forward"
try:
# NOTE this is calling the git binary in a subprocess
repo.git.merge(branch)
except git.exc.GitCommandError:
# merge is expected to fail, run ifcmerge
try:
repo.git.mergetool(tool=mergetool)
except git.exc.GitCommandError as exc:
message = re.sub("( stderr: '|')", "", exc.stderr)
# ifcmerge failed, rollback
repo.git.merge(abort=True)
operator.report({"ERROR"}, "IFC Merge failed:" + message) @classmethod
return False def get_merge_tool(cls, branch_name: str) -> str:
else: if re.match("^(origin/)?(HEAD|main|master)$", branch_name):
if os.name == "nt": return "ifcmerge"
cls.dos2unix(path_ifc) return "ifcmerge-forward"
repo.index.add(os.path.normpath(path_ifc))
repo.git.commit("--no-edit")
except git.exc.GitError:
operator.report({"ERROR"}, "Unknown IFC Merge failure")
return False
props.display_branch = repo.active_branch.name @classmethod
def git_merge(cls, branch_name: str) -> Union[str, None]:
"""Attempt a git merge. Returns None on clean merge, 'conflict' on expected
GitCommandError, or 'error' on an unknown GitError."""
repo = IfcGitRepo.repo
branch = repo.branches[branch_name]
try:
repo.git.merge(branch)
return None
except git.exc.GitCommandError:
return "conflict"
except git.exc.GitError:
return "error"
cls.load_project(path_ifc) @classmethod
cls.refresh_revision_list(path_ifc) def git_mergetool(cls, mergetool: str) -> Union[str, None]:
cls.decolourise() """Run ifcmerge tool. Returns None on success, error message string on failure."""
repo = IfcGitRepo.repo
try:
repo.git.mergetool(tool=mergetool)
return None
except git.exc.GitCommandError as exc:
return re.sub("( stderr: '|')", "", exc.stderr)
@classmethod
def git_merge_abort(cls) -> None:
IfcGitRepo.repo.git.merge(abort=True)
@classmethod
def commit_merge(cls, path_ifc: str) -> None:
repo = IfcGitRepo.repo
if os.name == "nt":
cls.dos2unix(path_ifc)
repo.index.add(os.path.normpath(path_ifc))
repo.git.commit("--no-edit")
@classmethod
def set_display_branch(cls) -> None:
props = cls.get_ifcgit_props()
props.display_branch = IfcGitRepo.repo.active_branch.name
@classmethod @classmethod
def entity_log(cls, path_ifc: str, step_id: int) -> str: def entity_log(cls, path_ifc: str, step_id: int) -> str:
@@ -589,6 +591,18 @@ class IfcGit:
except FileNotFoundError: except FileNotFoundError:
operator.report({"ERROR"}, "Winget is not available. Make sure Windows Package Manager is installed.") operator.report({"ERROR"}, "Winget is not available. Make sure Windows Package Manager is installed.")
@classmethod
def select_first_remote(cls) -> None:
props = cls.get_ifcgit_props()
repo = IfcGitRepo.repo
if repo and repo.remotes:
props.select_remote = repo.remotes[0].name
@classmethod
def fetch(cls, remote_name: str) -> None:
repo = IfcGitRepo.repo
repo.remotes[remote_name].fetch()
@classmethod @classmethod
def run_git_diff(cls, operator: bpy.types.Operator, save_to_temp: bool) -> None: def run_git_diff(cls, operator: bpy.types.Operator, save_to_temp: bool) -> None:
path = tool.Ifc.get_path() path = tool.Ifc.get_path()
+7
View File
@@ -102,6 +102,13 @@ def geometry():
prophet.verify() prophet.verify()
@pytest.fixture
def ifcgit():
prophet = Prophecy(bonsai.core.tool.IfcGit)
yield prophet
prophet.verify()
@pytest.fixture @pytest.fixture
def georeference(): def georeference():
prophet = Prophecy(bonsai.core.tool.Georeference) prophet = Prophecy(bonsai.core.tool.Georeference)
+274
View File
@@ -0,0 +1,274 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# This file was generated with the assistance of an AI coding tool.
import pytest
import bonsai.core.ifcgit as subject
from test.core.bootstrap import ifcgit, ifc
class MockOperator:
def __init__(self):
self.reports = []
def report(self, level, message):
self.reports.append((level, message))
class TestCreateRepo:
def test_run(self, ifcgit, ifc):
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
ifcgit.get_path_dir("path/to/model.ifc").should_be_called().will_return("path/to")
ifcgit.init_repo("path/to").should_be_called()
subject.create_repo(ifcgit, ifc)
class TestAddFile:
def test_run(self, ifcgit, ifc):
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
ifcgit.repo_from_path("path/to/model.ifc").should_be_called().will_return("repo")
ifcgit.add_file_to_repo("repo", "path/to/model.ifc").should_be_called()
subject.add_file(ifcgit, ifc)
class TestCloneRepo:
def test_successful_clone(self, ifcgit):
ifcgit.clone_repo("http://example.com/repo.git", "/local/folder").should_be_called().will_return("repo")
ifcgit.load_anyifc("repo").should_be_called()
op = MockOperator()
subject.clone_repo(ifcgit, "http://example.com/repo.git", "/local/folder", operator=op)
assert op.reports == [({"INFO"}, "Repository cloned")]
def test_failed_clone_reports_error(self, ifcgit):
ifcgit.clone_repo("http://example.com/repo.git", "/local/folder").should_be_called().will_return(None)
op = MockOperator()
subject.clone_repo(ifcgit, "http://example.com/repo.git", "/local/folder", operator=op)
assert op.reports == [({"ERROR"}, "Clone failed")]
class TestDiscardUncommitted:
def test_run(self, ifcgit, ifc):
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
ifcgit.git_checkout("path/to/model.ifc").should_be_called()
ifcgit.load_project("path/to/model.ifc").should_be_called()
subject.discard_uncommitted(ifcgit, ifc)
class TestCommitChanges:
def test_commit_on_branch_without_new_branch(self, ifcgit, ifc):
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
ifcgit.is_head_detached().should_be_called().will_return(False)
ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called()
subject.commit_changes(ifcgit, ifc, "my message", "")
def test_commit_on_branch_with_new_branch(self, ifcgit, ifc):
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
ifcgit.is_head_detached().should_be_called().will_return(False)
ifcgit.checkout_new_branch("path/to/model.ifc", "feature").should_be_called()
ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called()
subject.commit_changes(ifcgit, ifc, "my message", "feature")
def test_commit_on_detached_head(self, ifcgit, ifc):
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
ifcgit.is_head_detached().should_be_called().will_return(True)
ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called()
ifcgit.create_new_branch("feature").should_be_called()
subject.commit_changes(ifcgit, ifc, "my message", "feature")
class TestAddTag:
def test_run(self, ifcgit):
ifcgit.add_tag("repo", "abc123", "v1.0", "Release notes").should_be_called()
subject.add_tag(ifcgit, "repo", "abc123", "v1.0", "Release notes")
class TestDeleteTag:
def test_run(self, ifcgit):
ifcgit.delete_tag("repo", "v1.0").should_be_called()
subject.delete_tag(ifcgit, "repo", "v1.0")
class TestAddRemote:
def test_run(self, ifcgit):
ifcgit.add_remote("repo", "origin", "http://example.com").should_be_called()
subject.add_remote(ifcgit, "repo", "origin", "http://example.com")
class TestDeleteRemote:
def test_run(self, ifcgit):
ifcgit.delete_remote("repo", "origin").should_be_called()
subject.delete_remote(ifcgit, "repo", "origin")
class TestPush:
def test_push_succeeds_silently(self, ifcgit):
ifcgit.get_active_branch_name().should_be_called().will_return("main")
ifcgit.push("repo", "origin", "main").should_be_called().will_return(None)
subject.push(ifcgit, "repo", "origin", operator=None)
def test_push_failure_reports_error(self, ifcgit):
ifcgit.get_active_branch_name().should_be_called().will_return("main")
ifcgit.push("repo", "origin", "main").should_be_called().will_return("stderr: rejected")
op = MockOperator()
subject.push(ifcgit, "repo", "origin", operator=op)
assert op.reports == [({"ERROR"}, "stderr: rejected")]
class TestRefreshRevisionList:
def test_refreshes_when_repo_has_heads(self, ifcgit, ifc):
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.repo_has_commits().should_be_called().will_return(False)
subject.refresh_revision_list(ifcgit, ifc)
# nothing else should be called — Prophecy will verify
class TestColouriseRevision:
def test_skips_when_no_step_ids(self, ifcgit):
ifcgit.get_revisions_step_ids().should_be_called().will_return(None)
subject.colourise_revision(ifcgit)
def test_colourises_with_step_ids(self, ifcgit):
ifcgit.get_revisions_step_ids().should_be_called().will_return("step_ids")
ifcgit.get_modified_step_ids("step_ids").should_be_called().will_return("modified_step_ids")
ifcgit.update_step_ids("step_ids", "modified_step_ids").should_be_called().will_return("final_step_ids")
ifcgit.colourise("final_step_ids").should_be_called()
subject.colourise_revision(ifcgit)
class TestColouriseUncommitted:
def test_skips_when_no_step_ids(self, ifcgit, ifc):
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
ifcgit.ifc_diff_ids("repo", None, "HEAD", "path/to/model.ifc").should_be_called().will_return(None)
subject.colourise_uncommitted(ifcgit, ifc, "repo")
def test_colourises_with_step_ids(self, ifcgit, ifc):
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
ifcgit.ifc_diff_ids("repo", None, "HEAD", "path/to/model.ifc").should_be_called().will_return("step_ids")
ifcgit.get_modified_step_ids("step_ids").should_be_called().will_return("modified_step_ids")
ifcgit.update_step_ids("step_ids", "modified_step_ids").should_be_called().will_return("final_step_ids")
ifcgit.colourise("final_step_ids").should_be_called()
subject.colourise_uncommitted(ifcgit, ifc, "repo")
class TestSwitchRevision:
def test_run(self, ifcgit, ifc):
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
ifcgit.switch_to_revision_item().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.switch_revision(ifcgit, ifc)
class TestMergeBranch:
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.merge_branch(ifcgit, ifc, operator=None)
def test_clean_merge(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("feature").should_be_called().will_return(None)
ifcgit.set_display_branch().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_success(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("feature").should_be_called().will_return("conflict")
ifcgit.git_mergetool("ifcmerge-forward").should_be_called().will_return(None)
ifcgit.commit_merge("path/to/model.ifc").should_be_called()
ifcgit.set_display_branch().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):
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_merge_abort().should_be_called()
op = MockOperator()
subject.merge_branch(ifcgit, ifc, op)
assert op.reports == [({"ERROR"}, "IFC Merge failed:merge error")]
def test_unknown_merge_error(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("feature").should_be_called().will_return("error")
op = MockOperator()
subject.merge_branch(ifcgit, ifc, op)
assert op.reports == [({"ERROR"}, "Unknown IFC Merge failure")]
class TestEntityLog:
def test_run(self, ifcgit, ifc):
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
ifcgit.entity_log("path/to/model.ifc", 42).should_be_called().will_return("log text")
op = MockOperator()
subject.entity_log(ifcgit, ifc, 42, op)
assert op.reports == [({"ERROR"}, "log text")]
class TestInstallGit:
def test_windows(self, ifcgit):
import unittest.mock as mock
with mock.patch("platform.system", return_value="Windows"):
ifcgit.install_git_windows(operator="op").should_be_called()
subject.install_git(ifcgit, "op")
def test_non_windows_does_nothing(self, ifcgit):
import unittest.mock as mock
with mock.patch("platform.system", return_value="Linux"):
subject.install_git(ifcgit, "op")
# no tool method should be called — Prophecy will verify
class TestFetch:
def test_run(self, ifcgit):
ifcgit.fetch("origin").should_be_called()
subject.fetch(ifcgit, "origin")
class TestRunGitDiff:
def test_run(self, ifcgit):
ifcgit.run_git_diff("operator", False).should_be_called()
subject.run_git_diff(ifcgit, "operator", False)
+456
View File
@@ -0,0 +1,456 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# This file was generated with the assistance of an AI coding tool.
import os
import tempfile
import bonsai.core.tool
from bonsai.tool.ifcgit import IfcGit, IfcGitRepo
from test.bim.bootstrap import NewFile
try:
import git
import git.exc
HAS_GIT = True
except ImportError:
HAS_GIT = False
import pytest
requires_git = pytest.mark.skipif(not HAS_GIT, reason="GitPython not available")
def _make_repo(tmpdir: str) -> "git.Repo":
"""Initialise a git repo with user config (needed for commits)."""
repo = git.Repo.init(tmpdir)
with repo.config_writer() as cfg:
cfg.set_value("user", "name", "Test User")
cfg.set_value("user", "email", "test@example.com")
return repo
def _commit_ifc(repo: "git.Repo", tmpdir: str, content: str, message: str) -> str:
"""Write content to model.ifc, stage and commit it. Returns commit hexsha."""
ifc_path = os.path.join(tmpdir, "model.ifc")
with open(ifc_path, "w") as f:
f.write(content)
repo.index.add([os.path.normpath(ifc_path)])
commit = repo.index.commit(message)
return commit.hexsha
# ---------------------------------------------------------------------------
# Interface conformance
# ---------------------------------------------------------------------------
class TestImplementsTool(NewFile):
def test_run(self):
assert isinstance(IfcGit(), bonsai.core.tool.IfcGit)
# ---------------------------------------------------------------------------
# Pure-logic (no git required)
# ---------------------------------------------------------------------------
class TestIsValidRefFormat(NewFile):
def test_simple_name(self):
assert IfcGit.is_valid_ref_format("main")
def test_name_with_slash(self):
assert IfcGit.is_valid_ref_format("feature/my-feature")
def test_name_with_numbers(self):
assert IfcGit.is_valid_ref_format("release-2024")
def test_rejects_leading_dot(self):
assert not IfcGit.is_valid_ref_format(".hidden")
def test_rejects_leading_dash(self):
assert not IfcGit.is_valid_ref_format("-branch")
def test_rejects_space(self):
assert not IfcGit.is_valid_ref_format("my branch")
def test_rejects_double_dot(self):
assert not IfcGit.is_valid_ref_format("my..branch")
def test_rejects_tilde(self):
assert not IfcGit.is_valid_ref_format("my~branch")
def test_rejects_caret(self):
assert not IfcGit.is_valid_ref_format("my^branch")
def test_rejects_trailing_dot(self):
assert not IfcGit.is_valid_ref_format("branch.")
def test_rejects_trailing_slash(self):
assert not IfcGit.is_valid_ref_format("branch/")
def test_rejects_dot_lock_suffix(self):
assert not IfcGit.is_valid_ref_format("branch.lock")
def test_rejects_empty_string(self):
assert not IfcGit.is_valid_ref_format("")
def test_rejects_at_brace(self):
assert not IfcGit.is_valid_ref_format("branch@{upstream}")
class TestGetPathDir(NewFile):
def test_returns_parent_directory(self):
assert IfcGit.get_path_dir("/some/path/model.ifc") == "/some/path"
def test_handles_nested_path(self):
assert IfcGit.get_path_dir("/a/b/c/d.ifc") == "/a/b/c"
def test_returns_absolute_path(self):
result = IfcGit.get_path_dir("/a/b/model.ifc")
assert os.path.isabs(result)
# ---------------------------------------------------------------------------
# File I/O
# ---------------------------------------------------------------------------
class TestDos2Unix(NewFile):
def test_converts_crlf_to_lf(self):
with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f:
f.write(b"line1\r\nline2\r\nline3\r\n")
path = f.name
try:
IfcGit.dos2unix(path)
with open(path, "rb") as f:
assert f.read() == b"line1\nline2\nline3\n"
finally:
os.unlink(path)
def test_lf_only_file_is_unchanged(self):
with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f:
f.write(b"line1\nline2\nline3\n")
path = f.name
try:
IfcGit.dos2unix(path)
with open(path, "rb") as f:
assert f.read() == b"line1\nline2\nline3\n"
finally:
os.unlink(path)
def test_empty_file_unchanged(self):
with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f:
f.write(b"")
path = f.name
try:
IfcGit.dos2unix(path)
with open(path, "rb") as f:
assert f.read() == b""
finally:
os.unlink(path)
# ---------------------------------------------------------------------------
# Git repo initialisation
# ---------------------------------------------------------------------------
class TestInitRepo(NewFile):
@requires_git
def test_creates_git_repo(self):
with tempfile.TemporaryDirectory() as tmpdir:
IfcGitRepo.repo = None
IfcGit.init_repo(tmpdir)
assert IfcGitRepo.repo is not None
assert os.path.isdir(IfcGitRepo.repo.git_dir)
IfcGitRepo.repo = None
@requires_git
def test_creates_info_attributes_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
IfcGitRepo.repo = None
IfcGit.init_repo(tmpdir)
attrs_path = os.path.join(IfcGitRepo.repo.git_dir, "info", "attributes")
assert os.path.isfile(attrs_path)
with open(attrs_path) as f:
assert "*.ifc text" in f.read()
IfcGitRepo.repo = None
class TestRepoFromPath(NewFile):
@requires_git
def test_finds_repo_from_file_in_root(self):
with tempfile.TemporaryDirectory() as tmpdir:
IfcGitRepo.repo = None
repo = _make_repo(tmpdir)
ifc_path = os.path.join(tmpdir, "model.ifc")
open(ifc_path, "w").close()
result = IfcGit.repo_from_path(ifc_path)
assert result is not None
assert os.path.abspath(result.working_dir) == os.path.abspath(tmpdir)
IfcGitRepo.repo = None
@requires_git
def test_finds_repo_from_subdirectory(self):
with tempfile.TemporaryDirectory() as tmpdir:
IfcGitRepo.repo = None
_make_repo(tmpdir)
subdir = os.path.join(tmpdir, "sub", "dir")
os.makedirs(subdir)
result = IfcGit.repo_from_path(subdir)
assert result is not None
IfcGitRepo.repo = None
@requires_git
def test_returns_none_for_path_outside_any_repo(self):
with tempfile.TemporaryDirectory() as tmpdir:
IfcGitRepo.repo = None
# a plain directory with no .git anywhere above it (using /tmp directly
# is safe since /tmp is not a git repo on this system)
result = IfcGit.repo_from_path("/nonexistent/path/that/does/not/exist")
assert result is None
IfcGitRepo.repo = None
# ---------------------------------------------------------------------------
# Git repo configuration
# ---------------------------------------------------------------------------
class TestConfigInfoAttributes(NewFile):
@requires_git
def test_creates_attributes_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
IfcGit.config_info_attributes(repo)
attrs_path = os.path.join(repo.git_dir, "info", "attributes")
assert os.path.isfile(attrs_path)
with open(attrs_path) as f:
assert "*.ifc text" in f.read()
@requires_git
def test_does_not_overwrite_existing_attributes(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
attrs_path = os.path.join(repo.git_dir, "info", "attributes")
os.makedirs(os.path.dirname(attrs_path), exist_ok=True)
with open(attrs_path, "w") as f:
f.write("*.png binary\n")
IfcGit.config_info_attributes(repo)
with open(attrs_path) as f:
content = f.read()
assert "*.png binary" in content # original content preserved
class TestConfigPush(NewFile):
@requires_git
def test_sets_push_defaults(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
IfcGit.config_push(repo)
reader = repo.config_reader()
assert reader.get_value("push", "default") == "current"
assert reader.get_value("push", "autoSetupRemote") is True
@requires_git
def test_does_not_overwrite_existing_push_section(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
with repo.config_writer() as w:
w.set_value("push", "default", "simple")
IfcGit.config_push(repo)
reader = repo.config_reader()
assert reader.get_value("push", "default") == "simple"
# ---------------------------------------------------------------------------
# Branch / tag lookups
# ---------------------------------------------------------------------------
class TestBranchesByHexsha(NewFile):
@requires_git
def test_maps_head_commit_to_active_branch(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
result = IfcGit.branches_by_hexsha(repo)
head_sha = repo.head.commit.hexsha
assert head_sha in result
names = [b.name for b in result[head_sha]]
assert repo.active_branch.name in names
@requires_git
def test_returns_empty_dict_when_no_commits(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
result = IfcGit.branches_by_hexsha(repo)
assert result == {}
@requires_git
def test_includes_both_branches_when_pointing_to_same_commit(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
repo.create_head("feature")
result = IfcGit.branches_by_hexsha(repo)
head_sha = repo.head.commit.hexsha
names = [b.name for b in result[head_sha]]
assert len(names) == 2
class TestTagsByHexsha(NewFile):
@requires_git
def test_returns_empty_dict_when_no_tags(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
assert IfcGit.tags_by_hexsha(repo) == {}
@requires_git
def test_maps_commit_to_annotated_tag(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
repo.create_tag("v1.0", message="Release 1.0")
result = IfcGit.tags_by_hexsha(repo)
head_sha = repo.head.commit.hexsha
assert head_sha in result
assert result[head_sha][0].name == "v1.0"
@requires_git
def test_maps_commit_to_lightweight_tag(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
repo.create_tag("v0.1")
result = IfcGit.tags_by_hexsha(repo)
head_sha = repo.head.commit.hexsha
assert head_sha in result
class TestDeleteTag(NewFile):
@requires_git
def test_removes_existing_tag(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
repo.create_tag("v1.0")
IfcGit.delete_tag(repo, "v1.0")
assert "v1.0" not in [t.name for t in repo.tags]
@requires_git
def test_does_not_raise_for_nonexistent_tag(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
IfcGit.delete_tag(repo, "does-not-exist") # must not raise
# ---------------------------------------------------------------------------
# IFC diff parsing
# ---------------------------------------------------------------------------
class TestIfcDiffIds(NewFile):
@requires_git
def test_detects_modified_entity(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
ifc_path = os.path.join(tmpdir, "model.ifc")
sha_b = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n", "update")
result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
assert 1 in result["modified"]
assert result["added"] == set()
assert result["removed"] == set()
@requires_git
def test_detects_added_entity(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
ifc_path = os.path.join(tmpdir, "model.ifc")
sha_b = _commit_ifc(
repo,
tmpdir,
"#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n#2=IFCSITE('new',$,$,$,$,$,$,$,$,$,$,$,$);\n",
"add site",
)
result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
assert 2 in result["added"]
assert 1 not in result["modified"]
assert result["removed"] == set()
@requires_git
def test_detects_removed_entity(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
ifc_path = os.path.join(tmpdir, "model.ifc")
sha_b = _commit_ifc(repo, tmpdir, "", "clear")
result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
assert 1 in result["removed"]
assert result["added"] == set()
assert result["modified"] == set()
@requires_git
def test_no_changes_between_identical_commits(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
sha = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
ifc_path = os.path.join(tmpdir, "model.ifc")
result = IfcGit.ifc_diff_ids(repo, sha, sha, ifc_path)
assert result["modified"] == set()
assert result["added"] == set()
assert result["removed"] == set()
@requires_git
def test_diff_against_working_tree_with_none_hash_a(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
ifc_path = os.path.join(tmpdir, "model.ifc")
# Make an uncommitted change in working tree
with open(ifc_path, "w") as f:
f.write("#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n")
result = IfcGit.ifc_diff_ids(repo, None, "HEAD", ifc_path)
assert 1 in result["modified"]
@requires_git
def test_handles_multiple_changed_entities(self):
with tempfile.TemporaryDirectory() as tmpdir:
repo = _make_repo(tmpdir)
sha_a = _commit_ifc(
repo,
tmpdir,
"#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n#2=IFCSITE('s',$,$,$,$,$,$,$,$,$,$,$,$);\n",
"init",
)
ifc_path = os.path.join(tmpdir, "model.ifc")
sha_b = _commit_ifc(
repo,
tmpdir,
"#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n#2=IFCSITE('t',$,$,$,$,$,$,$,$,$,$,$,$);\n",
"update both",
)
result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
assert 1 in result["modified"]
assert 2 in result["modified"]