From c622e6ca4217c8d2fd445a97bae1d9cd504e3d14 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Sat, 27 Jan 2024 08:46:33 -0600 Subject: [PATCH] Addon Manager: Complete migration away from GitPython --- src/Mod/AddonManager/addonmanager_git.py | 80 ++++++++- src/Mod/AddonManager/change_branch.py | 203 +++++++++-------------- src/Mod/AddonManager/change_branch.ui | 2 +- src/Mod/AddonManager/package_details.py | 32 ++-- 4 files changed, 176 insertions(+), 141 deletions(-) diff --git a/src/Mod/AddonManager/addonmanager_git.py b/src/Mod/AddonManager/addonmanager_git.py index e3a51b1ae1..7bbcd542e5 100644 --- a/src/Mod/AddonManager/addonmanager_git.py +++ b/src/Mod/AddonManager/addonmanager_git.py @@ -29,7 +29,7 @@ import os import platform import shutil import subprocess -from typing import List, Optional +from typing import List, Dict, Optional import time import addonmanager_utilities as utils @@ -46,6 +46,30 @@ class GitFailed(RuntimeError): """The call to git returned an error of some kind""" +def _ref_format_string() -> str: + return ( + "--format=%(refname:lstrip=2)\t%(upstream:lstrip=2)\t%(authordate:rfc)\t%(" + "authorname)\t%(subject)" + ) + + +def _parse_ref_table(text: str): + rows = text.splitlines() + result = [] + for row in rows: + columns = row.split("\t") + result.append( + { + "ref_name": columns[0], + "upstream": columns[1], + "date": columns[2], + "author": columns[3], + "subject": columns[4], + } + ) + return result + + class GitManager: """A class to manage access to git: mostly just provides a simple wrapper around the basic command-line calls. Provides optional asynchronous access to clone and @@ -82,6 +106,36 @@ class GitManager: self._synchronous_call_git(final_args) os.chdir(old_dir) + def dirty(self, local_path: str) -> bool: + """Check for local changes""" + old_dir = os.getcwd() + os.chdir(local_path) + result = False + final_args = ["diff-index", "HEAD"] + try: + stdout = self._synchronous_call_git(final_args) + if stdout: + result = True + except GitFailed: + result = False + os.chdir(old_dir) + return result + + def detached_head(self, local_path: str) -> bool: + """Check for detached head state""" + old_dir = os.getcwd() + os.chdir(local_path) + result = False + final_args = ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "HEAD"] + try: + stdout = self._synchronous_call_git(final_args) + if stdout == "HEAD": + result = True + except GitFailed: + result = False + os.chdir(old_dir) + return result + def update(self, local_path): """Fetches and pulls the local_path from its remote""" old_dir = os.getcwd() @@ -258,6 +312,30 @@ class GitManager: branches.append(branch) return branches + def get_branches_with_info(self, local_path) -> List[Dict[str, str]]: + """Get a list of branches, where each entry is a dictionary with status information about + the branch.""" + old_dir = os.getcwd() + os.chdir(local_path) + try: + stdout = self._synchronous_call_git(["branch", "-a", _ref_format_string()]) + return _parse_ref_table(stdout) + except GitFailed as e: + os.chdir(old_dir) + raise e + + def get_tags_with_info(self, local_path) -> List[Dict[str, str]]: + """Get a list of branches, where each entry is a dictionary with status information about + the branch.""" + old_dir = os.getcwd() + os.chdir(local_path) + try: + stdout = self._synchronous_call_git(["tag", "-l", _ref_format_string()]) + return _parse_ref_table(stdout) + except GitFailed as e: + os.chdir(old_dir) + raise e + def get_last_committers(self, local_path, n=10): """Examine the last n entries of the commit history, and return a list of all the committers, their email addresses, and how many commits each one is diff --git a/src/Mod/AddonManager/change_branch.py b/src/Mod/AddonManager/change_branch.py index ebe1819bc7..bb2f04fff9 100644 --- a/src/Mod/AddonManager/change_branch.py +++ b/src/Mod/AddonManager/change_branch.py @@ -25,24 +25,18 @@ import os import FreeCAD import FreeCADGui +from addonmanager_git import initialize_git from PySide import QtWidgets, QtCore translate = FreeCAD.Qt.translate -try: - import git - - NO_GIT = False -except Exception: - NO_GIT = True - class ChangeBranchDialog(QtWidgets.QWidget): branch_changed = QtCore.Signal(str) - def __init__(self, path: os.PathLike, parent=None): + def __init__(self, path: str, parent=None): super().__init__(parent) self.ui = FreeCADGui.PySideUic.loadUi( @@ -56,21 +50,20 @@ class ChangeBranchDialog(QtWidgets.QWidget): self.item_model = ChangeBranchDialogModel(path, self) self.item_filter.setSourceModel(self.item_model) self.ui.tableView.sortByColumn( - 4, QtCore.Qt.DescendingOrder + 2, QtCore.Qt.DescendingOrder ) # Default to sorting by remote last-changed date # Figure out what row gets selected: + git_manager = initialize_git() row = 0 - current_ref = self.item_model.repo.head.ref + current_ref = git_manager.current_branch(path) selection_model = self.ui.tableView.selectionModel() - for ref in self.item_model.refs: - if ref == current_ref: + for ref in self.item_model.branches: + if ref["ref_name"] == current_ref: index = self.item_filter.mapFromSource(self.item_model.index(row, 0)) selection_model.select(index, QtCore.QItemSelectionModel.ClearAndSelect) selection_model.select(index.siblingAtColumn(1), QtCore.QItemSelectionModel.Select) selection_model.select(index.siblingAtColumn(2), QtCore.QItemSelectionModel.Select) - selection_model.select(index.siblingAtColumn(3), QtCore.QItemSelectionModel.Select) - selection_model.select(index.siblingAtColumn(4), QtCore.QItemSelectionModel.Select) break row += 1 @@ -85,7 +78,7 @@ class ChangeBranchDialog(QtWidgets.QWidget): index = self.item_filter.mapToSource(selection[0]) ref = self.item_model.data(index, ChangeBranchDialogModel.RefAccessRole) - if ref == self.item_model.repo.head.ref: + if ref["ref_name"] == self.item_model.current_branch: # This is the one we are already on... just return return @@ -94,20 +87,24 @@ class ChangeBranchDialog(QtWidgets.QWidget): translate("AddonsInstaller", "DANGER: Developer feature"), translate( "AddonsInstaller", - "DANGER: Switching branches is intended for developers and beta testers, and may result in broken, non-backwards compatible documents, instability, crashes, and/or the premature heat death of the universe. Are you sure you want to continue?", + "DANGER: Switching branches is intended for developers and beta testers, " + "and may result in broken, non-backwards compatible documents, instability, " + "crashes, and/or the premature heat death of the universe. Are you sure you " + "want to continue?", ), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel, QtWidgets.QMessageBox.Cancel, ) if result == QtWidgets.QMessageBox.Cancel: return - if self.item_model.repo.is_dirty(): + if self.item_model.dirty: result = QtWidgets.QMessageBox.critical( self, translate("AddonsInstaller", "There are local changes"), translate( "AddonsInstaller", - "WARNING: This repo has uncommitted local changes. Are you sure you want to change branches (bringing the changes with you)?", + "WARNING: This repo has uncommitted local changes. Are you sure you want " + "to change branches (bringing the changes with you)?", ), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel, QtWidgets.QMessageBox.Cancel, @@ -115,93 +112,41 @@ class ChangeBranchDialog(QtWidgets.QWidget): if result == QtWidgets.QMessageBox.Cancel: return - if isinstance(ref, git.TagReference): - # Detach the head - self.item_model.repo.head.reference = ref - self.item_model.repo.head.reset(index=True, working_tree=True) - elif isinstance(ref, git.RemoteReference): - # Set up a local tracking branch - slash_index = ref.name.find("/") - if slash_index != -1: - local_name = ref.name[slash_index + 1 :] - else: - local_name = ref.name - self.item_model.repo.create_head(local_name, ref) - self.item_model.repo.heads[local_name].set_tracking_branch(ref) - self.item_model.repo.heads[local_name].checkout() + gm = initialize_git() + remote_name = ref["ref_name"] + _, _, local_name = ref["ref_name"].rpartition("/") + if ref["upstream"]: + gm.checkout(self.item_model.path, remote_name) else: - # It's already a local branch, just check it out - ref.checkout() - - self.branch_changed.emit(ref.name) + gm.checkout(self.item_model.path, remote_name, args=["-b", local_name]) + self.branch_changed.emit(local_name) class ChangeBranchDialogModel(QtCore.QAbstractTableModel): - refs = [] - display_data = [] + branches = [] DataSortRole = QtCore.Qt.UserRole RefAccessRole = QtCore.Qt.UserRole + 1 - def __init__(self, path: os.PathLike, parent=None) -> None: + def __init__(self, path: str, parent=None) -> None: super().__init__(parent) - self.repo = git.Repo(path) - self.refs = [] - tracking_refs = [] - for ref in self.repo.refs: - row = ["", None, None, None, None] - if "HEAD" in ref.name: - continue - if isinstance(ref, git.RemoteReference): - if ref.name in tracking_refs: - # Already seen, it's the remote part of a remote tracking branch - continue - else: - # Just a remote branch, not tracking: - row[0] = translate("AddonsInstaller", "Branch", "git terminology") - row[2] = ref.name - if hasattr(ref, "commit") and hasattr(ref.commit, "committed_date"): - row[4] = ref.commit.committed_date - else: - row[4] = ref.log_entry(0).time[0] - elif isinstance(ref, git.TagReference): - # Tags are simple, there is no tracking to worry about - row[0] = translate("AddonsInstaller", "Tag", "git terminology") - row[1] = ref.name - row[3] = ref.commit.committed_date - elif isinstance(ref, git.Head): - if hasattr(ref, "tracking_branch") and ref.tracking_branch(): - # This local branch tracks a remote: we have all five pieces of data... - row[0] = translate("AddonsInstaller", "Branch", "git terminology") - row[1] = ref.name - row[2] = ref.tracking_branch().name - row[3] = ref.commit.committed_date - row[4] = ref.tracking_branch().commit.committed_date - tracking_refs.append(ref.tracking_branch().name) - else: - # Just a local branch, no remote tracking: - row[0] = translate("AddonsInstaller", "Branch", "git terminology") - row[1] = ref.name - if hasattr(ref, "commit") and hasattr(ref.commit, "committed_date"): - row[3] = ref.commit.committed_date - else: - row[3] = ref.log_entry(0).time[0] - else: - continue - - self.display_data.append(row.copy()) - self.refs.append(ref) + gm = initialize_git() + self.path = path + self.branches = gm.get_branches_with_info(path) + self.current_branch = gm.current_branch(path) + self.dirty = gm.dirty(path) + self._remove_tracking_duplicates() def rowCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int: if parent.isValid(): return 0 - return len(self.refs) + return len(self.branches) def columnCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int: if parent.isValid(): return 0 - return 5 # Type, local name, tracking name, local update, and remote update + return 3 # Local name, remote name, date def data(self, index: QtCore.QModelIndex, role: int = QtCore.Qt.DisplayRole): if not index.isValid(): @@ -209,31 +154,39 @@ class ChangeBranchDialogModel(QtCore.QAbstractTableModel): row = index.row() column = index.column() if role == QtCore.Qt.ToolTipRole: - tooltip = "" - # TODO: What should the tooltip be for these items? Last commit message? + tooltip = self.branches[row]["author"] + ": " + self.branches[row]["subject"] return tooltip elif role == QtCore.Qt.DisplayRole: - dd = self.display_data[row] - if column == 3 or column == 4: - if dd[column] is not None: - qdate = QtCore.QDateTime.fromSecsSinceEpoch(dd[column]) - return QtCore.QLocale().toString(qdate, QtCore.QLocale.ShortFormat) - elif column < len(dd): - return dd[column] + dd = self.branches[row] + if column == 2: + if dd["date"] is not None: + q_date = QtCore.QDateTime.fromString( + dd["date"], QtCore.Qt.DateFormat.RFC2822Date + ) + return QtCore.QLocale().toString(q_date, QtCore.QLocale.ShortFormat) + return None + elif column == 0: + return dd["ref_name"] + elif column == 1: + return dd["upstream"] else: return None elif role == ChangeBranchDialogModel.DataSortRole: - if column == 0: - if self.refs[row] in self.repo.heads: - return 0 - else: - return 1 - elif column < len(self.display_data[row]): - return self.display_data[row][column] + if column == 2: + if self.branches[row]["date"] is not None: + q_date = QtCore.QDateTime.fromString( + self.branches[row]["date"], QtCore.Qt.DateFormat.RFC2822Date + ) + return q_date + return None + elif column == 0: + return self.branches[row]["ref_name"] + elif column == 1: + return self.branches[row]["upstream"] else: return None elif role == ChangeBranchDialogModel.RefAccessRole: - return self.refs[row] + return self.branches[row] def headerData( self, @@ -248,32 +201,38 @@ class ChangeBranchDialogModel(QtCore.QAbstractTableModel): if section == 0: return translate( "AddonsInstaller", - "Kind", - "Table header for git ref type (e.g. either Tag or Branch)", + "Local", + "Table header for local git ref name", + ) + if section == 1: + return translate( + "AddonsInstaller", + "Remote tracking", + "Table header for git remote tracking branch name", ) - elif section == 1: - return translate("AddonsInstaller", "Local name", "Table header for git ref name") elif section == 2: return translate( "AddonsInstaller", - "Tracking", - "Table header for git remote tracking branch name name", - ) - elif section == 3: - return translate( - "AddonsInstaller", - "Local updated", - "Table header for git update time of local branch", - ) - elif section == 4: - return translate( - "AddonsInstaller", - "Remote updated", - "Table header for git update time of remote branch", + "Last Updated", + "Table header for git update date", ) else: return None + def _remove_tracking_duplicates(self): + remote_tracking_branches = [] + branches_to_keep = [] + for branch in self.branches: + if branch["upstream"]: + remote_tracking_branches.append(branch["upstream"]) + for branch in self.branches: + if ( + "HEAD" not in branch["ref_name"] + and branch["ref_name"] not in remote_tracking_branches + ): + branches_to_keep.append(branch) + self.branches = branches_to_keep + class ChangeBranchDialogFilter(QtCore.QSortFilterProxyModel): def lessThan(self, left: QtCore.QModelIndex, right: QtCore.QModelIndex): diff --git a/src/Mod/AddonManager/change_branch.ui b/src/Mod/AddonManager/change_branch.ui index c7cd0e7bc6..5336eccaed 100644 --- a/src/Mod/AddonManager/change_branch.ui +++ b/src/Mod/AddonManager/change_branch.ui @@ -20,7 +20,7 @@ - Change to branch or tag: + Change to branch: true diff --git a/src/Mod/AddonManager/package_details.py b/src/Mod/AddonManager/package_details.py index a9e73a42c7..62f8cb665d 100644 --- a/src/Mod/AddonManager/package_details.py +++ b/src/Mod/AddonManager/package_details.py @@ -34,19 +34,10 @@ import addonmanager_utilities as utils from addonmanager_metadata import Version, UrlType, get_first_supported_freecad_version from addonmanager_workers_startup import GetMacroDetailsWorker, CheckSingleUpdateWorker from addonmanager_readme_viewer import ReadmeViewer +from addonmanager_git import GitManager, NoGitFound, GitFailed from Addon import Addon from change_branch import ChangeBranchDialog -have_git = False -try: - import git - - if hasattr(git, "Repo"): - have_git = True -except ImportError: - git = None - - translate = fci.translate @@ -70,6 +61,10 @@ class PackageDetails(QtWidgets.QWidget): self.worker = None self.repo = None self.status_update_thread = None + try: + self.git_manager = GitManager() + except NoGitFound: + self.git_manager = None self.ui.buttonBack.clicked.connect(self.back.emit) self.ui.buttonExecute.clicked.connect(lambda: self.execute.emit(self.repo)) @@ -183,14 +178,16 @@ class PackageDetails(QtWidgets.QWidget): elif status == Addon.Status.NO_UPDATE_AVAILABLE: detached_head = False branch = repo.branch - if have_git and repo.repo_type != Addon.Kind.MACRO: + if self.git_manager and repo.repo_type != Addon.Kind.MACRO: basedir = fci.getUserAppDataDir() moddir = os.path.join(basedir, "Mod", repo.name) - if os.path.exists(os.path.join(moddir, ".git")): - gitrepo = git.Repo(moddir) - branch = gitrepo.head.ref.name - detached_head = gitrepo.head.is_detached - + repo_path = os.path.join(moddir, ".git") + if os.path.exists(repo_path): + branch = self.git_manager.current_branch(repo_path) + if self.git_manager.detached_head(repo_path): + tag = self.git_manager.current_tag(repo_path) + branch = tag + detached_head = True if detached_head: installed_version_string += ( translate( @@ -356,7 +353,7 @@ class PackageDetails(QtWidgets.QWidget): return # Can we actually switch branches? If not, return. - if not have_git: + if not self.git_manager: return # Is there a .git subdirectory? If not, return. @@ -367,6 +364,7 @@ class PackageDetails(QtWidgets.QWidget): # If all four above checks passed, then it's possible for us to switch # branches, if there are any besides the one we are on: show the button + print("Showing the button") self.ui.buttonChangeBranch.show() def set_disable_button_state(self):