import os import re import git import bpy import time from blenderbim.bim.ifc import IfcStore import blenderbim.tool as tool bl_info = { "name": "IFC Git", "author": "Bruno Postle", "location": "Scene > IFC Git", "description": "Manage IFC files in Git repositories", "blender": (2, 80, 0), "category": "Import-Export", } # This program 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 2 # of the License, or (at your option) any later version. # # This program 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 this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # 2023 Bruno Postle # GUI CLASSES class IFCGIT_PT_panel(bpy.types.Panel): """Scene Properties panel to interact with IFC repository data""" bl_label = "IFC Git" bl_space_type = "PROPERTIES" bl_region_type = "WINDOW" bl_context = "scene" bl_options = {"DEFAULT_CLOSED"} bl_parent_id = "BIM_PT_project_info" def draw(self, context): layout = self.layout path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file # TODO if file isn't saved, offer to save to disk row = layout.row() if path_ifc: # FIXME shouldn't be a global global ifcgit_repo ifcgit_repo = repo_from_path(path_ifc) if ifcgit_repo: name_ifc = os.path.relpath(path_ifc, ifcgit_repo.working_dir) row.label(text=ifcgit_repo.working_dir, icon="SYSTEM") if name_ifc in ifcgit_repo.untracked_files: row.operator( "ifcgit.addfile", text="Add '" + name_ifc + "' to repository", icon="FILE", ) else: row.label(text=name_ifc, icon="FILE") else: row.operator( "ifcgit.createrepo", text="Create '" + os.path.dirname(path_ifc) + "' repository", icon="SYSTEM", ) row.label(text=os.path.basename(path_ifc), icon="FILE") return else: row.label(text="No Git repository found", icon="SYSTEM") row.label(text="No IFC project saved", icon="FILE") return is_dirty = ifcgit_repo.is_dirty(path=path_ifc) if is_dirty: row = layout.row() row.label(text="Saved changes have not been committed", icon="ERROR") row = layout.row() row.operator("ifcgit.display_uncommitted", icon="SELECT_DIFFERENCE") row.operator("ifcgit.discard", icon="TRASH") row = layout.row() row.prop(context.scene, "commit_message") if ifcgit_repo.head.is_detached: row = layout.row() row.label( text="HEAD is detached, commit will create a branch", icon="ERROR" ) row.prop(context.scene, "new_branch_name") row = layout.row() row.operator("ifcgit.commit_changes", icon="GREASEPENCIL") row = layout.row() if ifcgit_repo.head.is_detached: row.label(text="Working branch: Detached HEAD") else: row.label(text="Working branch: " + ifcgit_repo.active_branch.name) grouped = layout.row() column = grouped.column() row = column.row() row.prop(bpy.context.scene, "display_branch", text="Browse branch") row.prop(bpy.context.scene, "ifcgit_filter", text="Filter revisions") row = column.row() row.template_list( "COMMIT_UL_List", "The_List", context.scene, "ifcgit_commits", context.scene, "commit_index", ) column = grouped.column() row = column.row() row.operator("ifcgit.refresh", icon="FILE_REFRESH") if not is_dirty: row = column.row() row.operator("ifcgit.display_revision", icon="SELECT_DIFFERENCE") row = column.row() row.operator("ifcgit.switch_revision", icon="CURRENT_FILE") # TODO operator to tag selected row = column.row() row.operator("ifcgit.merge", icon="EXPERIMENTAL", text="") if not context.scene.ifcgit_commits: return item = context.scene.ifcgit_commits[context.scene.commit_index] commit = ifcgit_repo.commit(rev=item.hexsha) if not item.relevant: row = layout.row() row.label(text="Revision unrelated to current IFC project", icon="ERROR") box = layout.box() column = box.column(align=True) row = column.row() row.label(text=commit.hexsha) row = column.row() row.label(text=commit.author.name + " <" + commit.author.email + ">") row = column.row() row.label(text=commit.message) class ListItem(bpy.types.PropertyGroup): """Group of properties representing an item in the list.""" hexsha: bpy.props.StringProperty( name="Git hash", description="checksum for this commit", default="Uncommitted data!", ) relevant: bpy.props.BoolProperty( name="Is relevant", description="does this commit reference our ifc file", default=False, ) class COMMIT_UL_List(bpy.types.UIList): """List of Git commits""" def draw_item( self, context, layout, data, item, icon, active_data, active_propname, index ): current_revision = ifcgit_repo.commit() commit = ifcgit_repo.commit(rev=item.hexsha) lookup = branches_by_hexsha(ifcgit_repo) refs = "" if item.hexsha in lookup: for branch in lookup[item.hexsha]: if branch.name == context.scene.display_branch: refs = "[" + branch.name + "] " lookup = tags_by_hexsha(ifcgit_repo) if item.hexsha in lookup: for tag in lookup[item.hexsha]: refs += "{" + tag.name + "} " if commit == current_revision: layout.label( text="[HEAD] " + refs + commit.message, icon="DECORATE_KEYFRAME" ) else: layout.label(text=refs + commit.message, icon="DECORATE_ANIMATE") layout.label(text=time.strftime("%c", time.localtime(commit.committed_date))) # OPERATORS class CreateRepo(bpy.types.Operator): """Initialise a Git repository""" bl_label = "Create Git repository" bl_idname = "ifcgit.createrepo" bl_options = {"REGISTER"} @classmethod def poll(cls, context): path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file if not os.path.isfile(path_ifc): return False if repo_from_path(path_ifc): # repo already exists return False if re.match("^/home/[^/]+/?$", os.path.dirname(path_ifc)): # don't make ${HOME} a repo return False return True def execute(self, context): path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file path_dir = os.path.abspath(os.path.dirname(path_ifc)) git.Repo.init(path_dir) return {"FINISHED"} class AddFileToRepo(bpy.types.Operator): """Add a file to a repository""" bl_label = "Add file to repository" bl_idname = "ifcgit.addfile" bl_options = {"REGISTER"} @classmethod def poll(cls, context): path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file if not os.path.isfile(path_ifc): return False if not repo_from_path(path_ifc): # repo doesn't exist return False return True def execute(self, context): path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file repo = repo_from_path(path_ifc) repo.index.add(path_ifc) repo.index.commit( message="Added " + os.path.relpath(path_ifc, repo.working_dir) ) bpy.ops.ifcgit.refresh() return {"FINISHED"} class DiscardUncommitted(bpy.types.Operator): """Discard saved changes and update to HEAD""" bl_label = "Discard uncommitted changes" bl_idname = "ifcgit.discard" bl_options = {"REGISTER"} def execute(self, context): path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file # NOTE this is calling the git binary in a subprocess ifcgit_repo.git.checkout(path_ifc) load_project(path_ifc) return {"FINISHED"} class CommitChanges(bpy.types.Operator): """Commit current saved changes""" bl_label = "Commit changes" bl_idname = "ifcgit.commit_changes" bl_options = {"REGISTER"} @classmethod def poll(cls, context): if context.scene.commit_message == "": return False if ifcgit_repo.head.is_detached and ( not is_valid_ref_format(context.scene.new_branch_name) or context.scene.new_branch_name in [branch.name for branch in ifcgit_repo.branches] ): return False return True def execute(self, context): path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file ifcgit_repo.index.add(path_ifc) ifcgit_repo.index.commit(message=context.scene.commit_message) context.scene.commit_message = "" if ifcgit_repo.head.is_detached: new_branch = ifcgit_repo.create_head(context.scene.new_branch_name) new_branch.checkout() context.scene.display_branch = context.scene.new_branch_name context.scene.new_branch_name = "" bpy.ops.ifcgit.refresh() return {"FINISHED"} class RefreshGit(bpy.types.Operator): """Refresh revision list""" bl_label = "" bl_idname = "ifcgit.refresh" bl_options = {"REGISTER"} @classmethod def poll(cls, context): if "ifcgit_repo" in globals() and ifcgit_repo != None and ifcgit_repo.heads: return True return False def execute(self, context): area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D") area.spaces[0].shading.color_type = "MATERIAL" # ifcgit_commits is registered list widget context.scene.ifcgit_commits.clear() path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file commits = list( git.objects.commit.Commit.iter_items( repo=ifcgit_repo, rev=[context.scene.display_branch], ) ) commits_relevant = list( git.objects.commit.Commit.iter_items( repo=ifcgit_repo, rev=[context.scene.display_branch], paths=[path_ifc], ) ) lookup = tags_by_hexsha(ifcgit_repo) for commit in commits: if context.scene.ifcgit_filter == "tagged" and not commit.hexsha in lookup: continue elif ( context.scene.ifcgit_filter == "relevant" and not commit in commits_relevant ): continue context.scene.ifcgit_commits.add() context.scene.ifcgit_commits[-1].hexsha = commit.hexsha if commit in commits_relevant: context.scene.ifcgit_commits[-1].relevant = True return {"FINISHED"} class DisplayRevision(bpy.types.Operator): """Colourise objects by selected revision""" bl_label = "" bl_idname = "ifcgit.display_revision" bl_options = {"REGISTER"} def execute(self, context): path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file item = context.scene.ifcgit_commits[context.scene.commit_index] selected_revision = ifcgit_repo.commit(rev=item.hexsha) current_revision = ifcgit_repo.commit() if selected_revision == current_revision: area = next( area for area in bpy.context.screen.areas if area.type == "VIEW_3D" ) area.spaces[0].shading.color_type = "MATERIAL" return {"FINISHED"} if current_revision.committed_date > selected_revision.committed_date: step_ids = ifc_diff_ids( ifcgit_repo, selected_revision.hexsha, current_revision.hexsha, path_ifc ) else: step_ids = ifc_diff_ids( ifcgit_repo, current_revision.hexsha, selected_revision.hexsha, path_ifc ) modified_shape_object_step_ids = get_modified_shape_object_step_ids(step_ids) final_step_ids = {} final_step_ids["added"] = step_ids["added"] final_step_ids["removed"] = step_ids["removed"] final_step_ids["modified"] = step_ids["modified"].union( modified_shape_object_step_ids["modified"] ) colourise(final_step_ids) return {"FINISHED"} class DisplayUncommitted(bpy.types.Operator): """Colourise uncommitted objects""" bl_label = "Show uncommitted changes" bl_idname = "ifcgit.display_uncommitted" bl_options = {"REGISTER"} def execute(self, context): path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file step_ids = ifc_diff_ids(ifcgit_repo, None, "HEAD", path_ifc) colourise(step_ids) return {"FINISHED"} class SwitchRevision(bpy.types.Operator): """Switches the repository to the selected revision and reloads the IFC file""" bl_label = "" bl_idname = "ifcgit.switch_revision" bl_options = {"REGISTER"} # FIXME bad things happen when switching to a revision that predates current project def execute(self, context): path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file item = context.scene.ifcgit_commits[context.scene.commit_index] lookup = branches_by_hexsha(ifcgit_repo) if item.hexsha in lookup: for branch in lookup[item.hexsha]: if branch.name == context.scene.display_branch: branch.checkout() else: # NOTE this is calling the git binary in a subprocess ifcgit_repo.git.checkout(item.hexsha) load_project(path_ifc) return {"FINISHED"} class Merge(bpy.types.Operator): """Merges the selected branch into working branch""" bl_label = "Merge this branch" bl_idname = "ifcgit.merge" bl_options = {"REGISTER"} def execute(self, context): path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file item = context.scene.ifcgit_commits[context.scene.commit_index] config_reader = ifcgit_repo.config_reader() section = 'mergetool "ifcmerge"' if not config_reader.has_section(section): config_writer = ifcgit_repo.config_writer() config_writer.set_value( section, "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED" ) config_writer.set_value(section, "trustExitCode", True) lookup = branches_by_hexsha(ifcgit_repo) if item.hexsha in lookup: for branch in lookup[item.hexsha]: if branch.name == context.scene.display_branch: # this is a branch! try: # NOTE this is calling the git binary in a subprocess ifcgit_repo.git.merge(branch) except git.exc.GitCommandError: # merge is expected to fail, run ifcmerge try: ifcgit_repo.git.mergetool(tool="ifcmerge") except: # ifcmerge failed, rollback ifcgit_repo.git.merge(abort=True) # FIXME need to report errors somehow self.report({"ERROR"}, "IFC Merge failed") return {"CANCELLED"} except: self.report({"ERROR"}, "Unknown IFC Merge failure") return {"CANCELLED"} ifcgit_repo.index.add(path_ifc) context.scene.commit_message = ( "Merged branch: " + context.scene.display_branch ) context.scene.display_branch = ifcgit_repo.active_branch.name load_project(path_ifc) return {"FINISHED"} else: return {"CANCELLED"} # FUNCTIONS def is_valid_ref_format(string): """Check a bare branch or tag name is valid""" return re.match( "^(?!\.| |-|/)((?!\.\.)(?!.*/\.)(/\*|/\*/)*(?!@\{)[^\~\:\^\\\ \?*\[])+(?