From 5e784e4175c83e5659335a709eea00ac4f35b876 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Wed, 1 Apr 2026 23:37:32 +0100 Subject: [PATCH] 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. --- src/bonsai/bonsai/bim/module/ifcgit/data.py | 143 +++--- .../bonsai/bim/module/ifcgit/operator.py | 68 ++- src/bonsai/bonsai/bim/module/ifcgit/prop.py | 26 +- src/bonsai/bonsai/bim/module/ifcgit/ui.py | 18 +- src/bonsai/bonsai/core/ifcgit.py | 63 ++- src/bonsai/bonsai/core/tool.py | 50 ++ src/bonsai/bonsai/tool/ifcgit.py | 180 +++---- src/bonsai/test/core/bootstrap.py | 7 + src/bonsai/test/core/test_ifcgit.py | 274 +++++++++++ src/bonsai/test/tool/test_ifcgit.py | 456 ++++++++++++++++++ 10 files changed, 1053 insertions(+), 232 deletions(-) create mode 100644 src/bonsai/test/core/test_ifcgit.py create mode 100644 src/bonsai/test/tool/test_ifcgit.py diff --git a/src/bonsai/bonsai/bim/module/ifcgit/data.py b/src/bonsai/bonsai/bim/module/ifcgit/data.py index 92da517a1c..7dbd26b5e0 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/data.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/data.py @@ -21,65 +21,71 @@ class IfcGitData: @classmethod 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 = { - "repo": cls.repo(), - "remotes": cls.remotes(), - "branch_names": cls.branch_names(), - "remote_names": cls.remote_names(), - "remote_urls": cls.remote_urls(), + "repo": repo, + "remotes": repo.remotes if repo else None, + "branch_names": cls.branch_names(repo), + "tag_names": cls.tag_names(repo), + "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(), "branches_by_hexsha": cls.branches_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(), "base_name": cls.base_name(), - "working_dir": cls.working_dir(), - "untracked_files": cls.untracked_files(), - "is_detached": cls.is_detached(), - "active_branch_name": cls.active_branch_name(), - "is_dirty": cls.is_dirty(), - "commit": cls.commit(), - "current_revision": cls.current_revision(), + "working_dir": repo.working_dir if repo else None, + "ifc_is_untracked": cls.ifc_is_untracked(repo), + "is_detached": repo.head.is_detached if repo else None, + "active_branch_name": repo.active_branch.name if repo and not repo.head.is_detached else None, + "is_dirty": cls.is_dirty(repo), + "current_revision": cls.current_revision(repo), "git_exe": cls.git_exe(), "ifcmerge_exe": cls.ifcmerge_exe(), } cls.is_loaded = True @classmethod - def repo(cls): - if bool(tool.Ifc.get()): - path_ifc = tool.Ifc.get_path() - if os.path.isfile(path_ifc): - return tool.IfcGit.repo_from_path(path_ifc) - return None + def branch_names(cls, repo): + if not repo or not repo.heads: + return [] + names = sorted([b.name for b in repo.branches]) + if "main" in names: + 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 - def remotes(cls): - if cls.repo(): - return cls.repo().remotes - return None + def tag_names(cls, repo): + if not repo: + return [] + return [t.name for t in repo.tags] @classmethod - def branch_names(cls): - return [] - - @classmethod - def remote_names(cls): - return [] - - @classmethod - def remote_urls(cls): - result = {} - if cls.repo(): - for remote in cls.repo().remotes: - result[remote.name] = remote.url - return result + def remote_names(cls, repo): + if not repo: + return [] + names = sorted([r.name for r in repo.remotes]) + if "origin" in names: + names.remove("origin") + names = ["origin"] + names + return names @classmethod def path_ifc(cls): path_ifc = tool.Ifc.get_path() if os.path.isfile(path_ifc): - return tool.Ifc.get_path() + return path_ifc return None @classmethod @@ -88,7 +94,8 @@ class IfcGitData: if tool.IfcGitRepo.repo.branches: return tool.IfcGit.branches_by_hexsha(tool.IfcGitRepo.repo) except AttributeError: - return {} + pass + return {} @classmethod def tags_by_hexsha(cls): @@ -97,12 +104,11 @@ class IfcGitData: return {} @classmethod - def name_ifc(cls): - if bool(tool.Ifc.get()): + def name_ifc(cls, repo): + if bool(tool.Ifc.get()) and repo: path_ifc = tool.Ifc.get_path() - if tool.IfcGitRepo.repo and os.path.isfile(path_ifc): - working_dir = tool.IfcGitRepo.repo.working_dir - return os.path.relpath(path_ifc, working_dir) + if os.path.isfile(path_ifc): + return os.path.relpath(path_ifc, repo.working_dir) return None @classmethod @@ -122,49 +128,28 @@ class IfcGitData: return None @classmethod - def working_dir(cls): - if cls.repo(): - return cls.repo().working_dir + def ifc_is_untracked(cls, repo): + """Return True if the IFC file exists in the repo but has not been added to git.""" + 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 - def untracked_files(cls): - if cls.repo(): - 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(): + def is_dirty(cls, repo): + if repo and cls.git_exe(): path_ifc = tool.Ifc.get_path() if os.path.isfile(path_ifc): - return cls.repo().is_dirty(path=path_ifc) + return repo.is_dirty(path=path_ifc) return False @classmethod - def commit(cls): + def current_revision(cls, repo): props = tool.IfcGit.get_ifcgit_props() - if cls.repo() and len(props.ifcgit_commits) > 0: - item = props.ifcgit_commits[props.commit_index] - 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() + if repo and repo.head.is_valid() and len(props.ifcgit_commits) > 0: + return repo.commit() @classmethod def git_exe(cls): diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index 829a0a62a2..5f01ef78ea 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -120,11 +120,11 @@ class CommitChanges(bpy.types.Operator): if props.commit_message == "": return False 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!") return False 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!") return False elif props.new_branch_name != "": @@ -134,10 +134,17 @@ class CommitChanges(bpy.types.Operator): def execute(self, context): - repo = IfcGitData.data["repo"] - core.commit_changes(tool.IfcGit, tool.Ifc, repo) - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + props = tool.IfcGit.get_ifcgit_props() + commit_message = props.commit_message + 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() + IfcGitData.load() + if new_branch_name: + props.display_branch = new_branch_name return {"FINISHED"} @@ -157,7 +164,7 @@ class AddTag(bpy.types.Operator): repo = IfcGitData.data["repo"] if repo and ( 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 True @@ -165,8 +172,12 @@ class AddTag(bpy.types.Operator): def execute(self, context): repo = IfcGitData.data["repo"] - core.add_tag(tool.IfcGit, repo) - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + props = tool.IfcGit.get_ifcgit_props() + 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() return {"FINISHED"} @@ -183,7 +194,7 @@ class DeleteTag(bpy.types.Operator): repo = IfcGitData.data["repo"] 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() return {"FINISHED"} @@ -205,8 +216,7 @@ class RefreshGit(bpy.types.Operator): def execute(self, context): - repo = IfcGitData.data["repo"] - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + core.refresh_revision_list(tool.IfcGit, tool.Ifc) refresh() tool.IfcGit.decolourise() return {"FINISHED"} @@ -284,7 +294,7 @@ class Merge(bpy.types.Operator): 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() return {"FINISHED"} else: @@ -314,9 +324,9 @@ class Fetch(bpy.types.Operator): def execute(self, context): props = tool.IfcGit.get_ifcgit_props() - repo = IfcGitData.data["repo"] - remote = repo.remotes[props.select_remote] - remote.fetch() + core.fetch(tool.IfcGit, props.select_remote) + core.refresh_revision_list(tool.IfcGit, tool.Ifc) + refresh() return {"FINISHED"} @@ -336,7 +346,7 @@ class AddRemote(bpy.types.Operator): not repo or not tool.IfcGit.is_valid_ref_format(props.remote_name) 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 True @@ -344,8 +354,11 @@ class AddRemote(bpy.types.Operator): def execute(self, context): repo = IfcGitData.data["repo"] - core.add_remote(tool.IfcGit, repo) - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + props = tool.IfcGit.get_ifcgit_props() + 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() return {"FINISHED"} @@ -360,8 +373,19 @@ class DeleteRemote(bpy.types.Operator): def execute(self, context): repo = IfcGitData.data["repo"] - core.delete_remote(tool.IfcGit, repo) - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + props = tool.IfcGit.get_ifcgit_props() + 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() return {"FINISHED"} @@ -375,8 +399,8 @@ class ObjectLog(bpy.types.Operator): @classmethod def poll(cls, context): - if not (obj := context.active_object): - cls.poll_message_set("No Active Object") + if not (obj := context.active_object) or not obj.select_get(): + cls.poll_message_set("No selected object") elif not tool.Blender.get_ifc_definition_id(obj): cls.poll_message_set("Active Object doesn't have an IFC definition") else: diff --git a/src/bonsai/bonsai/bim/module/ifcgit/prop.py b/src/bonsai/bonsai/bim/module/ifcgit/prop.py index cba3ca632c..865cb4eacf 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/prop.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/prop.py @@ -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: # NOTE "Python must keep a reference to the strings returned by # the callback or Blender will misbehave or even crash" - IfcGitData.data["branch_names"] = sorted([branch.name for branch in IfcGitData.data["repo"].heads]) - - if "main" 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"]] + # Branch list (local + remote, main first) is computed once in IfcGitData.load() + IfcGitData.make_sure_is_loaded() + return [(name, name, name) for name in IfcGitData.data["branch_names"]] 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"]]) - - 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"]] + IfcGitData.make_sure_is_loaded() + return [(name, name, name) for name in IfcGitData.data["remote_names"]] def update_revlist(self: "IfcGitProperties", context: bpy.types.Context) -> None: @@ -90,6 +76,7 @@ class IfcGitListItem(PropertyGroup): name="Commit Message", default="", ) + committed_date: IntProperty(name="Committed Date", default=0) tags: CollectionProperty(type=IfcGitTag, name="List of revision tags") if TYPE_CHECKING: @@ -98,6 +85,7 @@ class IfcGitListItem(PropertyGroup): author_name: str author_email: str message: str + committed_date: int tags: bpy.types.bpy_prop_collection_idprop[IfcGitTag] diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py index d8b7357e7b..6ac4aceb74 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -52,7 +52,7 @@ class IFCGIT_PT_panel(bpy.types.Panel): if IfcGitData.data["repo"] and os.path.exists(IfcGitData.data["repo"].git_dir): name_ifc = IfcGitData.data["name_ifc"] 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( "ifcgit.addfile", text="Add '" + name_ifc + "' to repository", @@ -216,13 +216,7 @@ class COMMIT_UL_List(bpy.types.UIList): ): current_revision = IfcGitData.data["current_revision"] - - # 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 + current_hexsha = current_revision.hexsha if current_revision else None lookup = IfcGitData.data["branches_by_hexsha"] refs = "" @@ -236,11 +230,11 @@ class COMMIT_UL_List(bpy.types.UIList): for tag in lookup[item.hexsha]: refs += "{" + tag.name + "} " - if commit == current_revision: - layout.label(text="[HEAD] " + refs + commit.message.split("\n")[0], icon="DECORATE_KEYFRAME") + if item.hexsha == current_hexsha: + layout.label(text="[HEAD] " + refs + item.message.split("\n")[0], icon="DECORATE_KEYFRAME") else: - layout.label(text=refs + commit.message.split("\n")[0], icon="DECORATE_ANIMATE") - layout.label(text=time.strftime("%c", time.localtime(commit.committed_date))) + layout.label(text=refs + item.message.split("\n")[0], icon="DECORATE_ANIMATE") + layout.label(text=time.strftime("%c", time.localtime(item.committed_date))) def draw_filter(self, context, layout): diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index fb23652ebc..f14dda6cbb 100644 --- a/src/bonsai/bonsai/core/ifcgit.py +++ b/src/bonsai/bonsai/core/ifcgit.py @@ -56,42 +56,45 @@ def discard_uncommitted(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None: 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""" path_ifc = ifc.get_path() - if repo.head.is_detached: - ifcgit.git_commit(path_ifc) - ifcgit.create_new_branch() + if ifcgit.is_head_detached(): + ifcgit.git_commit(path_ifc, commit_message) + ifcgit.create_new_branch(new_branch_name) else: - ifcgit.checkout_new_branch(path_ifc) - ifcgit.git_commit(path_ifc) + if new_branch_name: + 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: - ifcgit.add_tag(repo) +def add_tag(ifcgit: type[tool.IfcGit], repo: git.Repo, hexsha: str, tag_name: str, tag_message: str = "") -> None: + ifcgit.add_tag(repo, hexsha, tag_name, tag_message) def delete_tag(ifcgit: type[tool.IfcGit], repo: git.Repo, tag_name: git.TagReference) -> None: ifcgit.delete_tag(repo, tag_name) -def add_remote(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None: - ifcgit.add_remote(repo) +def add_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, remote_url: str) -> None: + ifcgit.add_remote(repo, remote_name, remote_url) -def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None: - ifcgit.delete_remote(repo) +def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str) -> None: + ifcgit.delete_remote(repo, remote_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, repo.active_branch.name) + error_message = ifcgit.push(repo, remote_name, ifcgit.get_active_branch_name()) if error_message: operator.report({"ERROR"}, error_message) -def refresh_revision_list(ifcgit: type[tool.IfcGit], repo: git.Repo, ifc: type[tool.Ifc]) -> None: - if repo.heads: +def refresh_revision_list(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None: + if ifcgit.repo_has_commits(): 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() -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() 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: @@ -145,5 +170,9 @@ def install_git(ifcgit: type[tool.IfcGit], operator: bpy.types.Operator) -> None 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: ifcgit.run_git_diff(operator, save_to_temp) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index f815bf0963..910ed79627 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -535,6 +535,56 @@ class Ifc: 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 class Layer: pass diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 64b3957169..afff9fa3dc 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -24,7 +24,7 @@ import re import subprocess import tempfile from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, Union +from typing import TYPE_CHECKING, Any, Union import bpy @@ -128,39 +128,27 @@ class IfcGit: cls.dos2unix(path_file) repo.index.add(os.path.normpath(path_file)) repo.index.commit(message="Added " + os.path.relpath(path_file, repo.working_dir)) - bpy.ops.ifcgit.refresh() @classmethod def git_checkout(cls, path_file: str) -> None: IfcGitRepo.repo.git.checkout(path_file) @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""" - props = cls.get_ifcgit_props() - 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() + IfcGitRepo.repo.git.checkout(b=branch_name) @classmethod - def git_commit(cls, path_file: str) -> None: - props = cls.get_ifcgit_props() + def git_commit(cls, path_file: str, commit_message: str) -> None: repo = IfcGitRepo.repo if os.name == "nt": cls.dos2unix(path_file) repo.index.add(os.path.normpath(path_file)) - repo.index.commit(message=props.commit_message) - props.commit_message = "" + repo.index.commit(message=commit_message) @classmethod - def add_tag(cls, repo: git.Repo) -> None: - props = cls.get_ifcgit_props() - 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 = "" + def add_tag(cls, repo: git.Repo, hexsha: str, tag_name: str, tag_message: str = "") -> None: + repo.create_tag(tag_name, ref=hexsha, message=tag_message) @classmethod def delete_tag(cls, repo: git.Repo, tag_name: git.TagReference) -> None: @@ -168,20 +156,13 @@ class IfcGit: repo.delete_tag(tag_name) @classmethod - def add_remote(cls, repo: git.Repo) -> None: - props = cls.get_ifcgit_props() - repo.create_remote(name=props.remote_name, url=props.remote_url) - props.remote_name = "" - props.remote_url = "" + def add_remote(cls, repo: git.Repo, remote_name: str, remote_url: str) -> None: + repo.create_remote(name=remote_name, url=remote_url) @classmethod - def delete_remote(cls, repo: git.Repo) -> None: - props = cls.get_ifcgit_props() - remote_name = props.select_remote + def delete_remote(cls, repo: git.Repo, remote_name: str) -> None: if remote_name in repo.remotes: repo.delete_remote(remote_name) - if repo.remotes: - props.select_remote = repo.remotes[0].name @classmethod def push(cls, repo: git.Repo, remote_name: str, branch_name: str) -> Union[str, None]: @@ -193,16 +174,25 @@ class IfcGit: return exc.stderr @classmethod - def create_new_branch(cls) -> None: - """Convert a detached HEAD into a branch""" - 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 = "" + def is_head_detached(cls) -> bool: + return bool(IfcGitRepo.repo.head.is_detached) - 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 def clear_commits_list(cls) -> None: @@ -243,6 +233,7 @@ class IfcGit: list_item.message = commit.message list_item.author_name = commit.author.name list_item.author_email = commit.author.email + list_item.committed_date = int(commit.committed_date) if commit in commits_relevant: list_item.relevant = True if commit.hexsha in lookup: @@ -286,6 +277,10 @@ class IfcGit: 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.should_setup_viewport_camera = False ifc_importer = import_ifc.IfcImporter(settings) @@ -469,15 +464,6 @@ class IfcGit: bpy.data.objects.remove(obj, do_unlink=True) 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 def config_ifcmerge(cls) -> None: config_reader = IfcGitRepo.repo.config_reader() @@ -519,49 +505,65 @@ class IfcGit: output.write(line + b"\n") @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() repo = IfcGitRepo.repo item = props.ifcgit_commits[props.commit_index] lookup = cls.branches_by_hexsha(repo) - if item.hexsha in lookup: - for branch in lookup[item.hexsha]: - if branch.name == props.display_branch: - # this is a branch! - if re.match("^(origin/)?(HEAD|main|master)$", branch.name): - # preserve remote IDs in origin/main or main - 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) + if item.hexsha not in lookup: + return None + for branch in lookup[item.hexsha]: + if branch.name == props.display_branch: + return branch.name + return None - operator.report({"ERROR"}, "IFC Merge failed:" + message) - return False - else: - if os.name == "nt": - cls.dos2unix(path_ifc) - 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 + @classmethod + def get_merge_tool(cls, branch_name: str) -> str: + if re.match("^(origin/)?(HEAD|main|master)$", branch_name): + return "ifcmerge" + return "ifcmerge-forward" - 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) - cls.refresh_revision_list(path_ifc) - cls.decolourise() + @classmethod + def git_mergetool(cls, mergetool: str) -> Union[str, None]: + """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 def entity_log(cls, path_ifc: str, step_id: int) -> str: @@ -589,6 +591,18 @@ class IfcGit: except FileNotFoundError: 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 def run_git_diff(cls, operator: bpy.types.Operator, save_to_temp: bool) -> None: path = tool.Ifc.get_path() diff --git a/src/bonsai/test/core/bootstrap.py b/src/bonsai/test/core/bootstrap.py index 057ee9ffab..cd8371e1c3 100644 --- a/src/bonsai/test/core/bootstrap.py +++ b/src/bonsai/test/core/bootstrap.py @@ -102,6 +102,13 @@ def geometry(): prophet.verify() +@pytest.fixture +def ifcgit(): + prophet = Prophecy(bonsai.core.tool.IfcGit) + yield prophet + prophet.verify() + + @pytest.fixture def georeference(): prophet = Prophecy(bonsai.core.tool.Georeference) diff --git a/src/bonsai/test/core/test_ifcgit.py b/src/bonsai/test/core/test_ifcgit.py new file mode 100644 index 0000000000..b4d80985b2 --- /dev/null +++ b/src/bonsai/test/core/test_ifcgit.py @@ -0,0 +1,274 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2025 Dion Moult +# +# 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 . +# 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) diff --git a/src/bonsai/test/tool/test_ifcgit.py b/src/bonsai/test/tool/test_ifcgit.py new file mode 100644 index 0000000000..5cd051aa5f --- /dev/null +++ b/src/bonsai/test/tool/test_ifcgit.py @@ -0,0 +1,456 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2025 Dion Moult +# +# 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 . +# 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"]