diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py index 72017d1a52..47f073ad45 100644 --- a/src/Mod/AddonManager/AddonManager.py +++ b/src/Mod/AddonManager/AddonManager.py @@ -757,6 +757,8 @@ class CommandAddonManager: if repo.status() == AddonManagerRepo.UpdateStatus.UPDATE_AVAILABLE: self.packages_with_updates.append(repo) self.enable_updates(len(self.packages_with_updates)) + elif repo.status() == AddonManagerRepo.UpdateStatus.PENDING_RESTART: + self.restart_required = True def enable_updates(self, number_of_updates: int) -> None: """enables the update button""" diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index 5e1d1c7584..4c24bfb158 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -13,6 +13,8 @@ SET(AddonManager_SRCS AddonManager.ui AddonManagerOptions.ui ALLOWED_PYTHON_PACKAGES.txt + change_branch.py + change_branch.ui first_run.ui compact_view.py dependency_resolution_dialog.ui diff --git a/src/Mod/AddonManager/addonmanager_workers.py b/src/Mod/AddonManager/addonmanager_workers.py index 4869168107..44faea6c02 100644 --- a/src/Mod/AddonManager/addonmanager_workers.py +++ b/src/Mod/AddonManager/addonmanager_workers.py @@ -433,21 +433,32 @@ class UpdateChecker: with wb.git_lock: utils.repair_git_repo(wb.url, clonedir) with wb.git_lock: - gitrepo = git.Git(clonedir) + gitrepo = git.Repo(clonedir) try: - gitrepo.fetch() - except Exception: + if gitrepo.head.is_detached: + # By definition, in a detached-head state we cannot + # update, so don't even bother checking. + wb.set_status(AddonManagerRepo.UpdateStatus.NO_UPDATE_AVAILABLE) + if hasattr(gitrepo.head, "ref"): + wb.branch = gitrepo.head.ref.name + else: + wb.branch = gitrepo.head.name + return + gitrepo.git.fetch() + except Exception as e: FreeCAD.Console.PrintWarning( "AddonManager: " + translate( "AddonsInstaller", "Unable to fetch git updates for workbench {}", ).format(wb.name) + + "\n" ) + FreeCAD.Console.PrintWarning(str(e) + "\n") wb.set_status(AddonManagerRepo.UpdateStatus.CANNOT_CHECK) else: try: - if "git pull" in gitrepo.status(): + if "git pull" in gitrepo.git.status(): wb.set_status( AddonManagerRepo.UpdateStatus.UPDATE_AVAILABLE ) @@ -460,12 +471,22 @@ class UpdateChecker: translate( "AddonsInstaller", "git fetch failed for {}" ).format(wb.name) + + "\n" ) wb.set_status(AddonManagerRepo.UpdateStatus.CANNOT_CHECK) def check_package(self, package: AddonManagerRepo) -> None: clonedir = self.moddir + os.sep + package.name if os.path.exists(clonedir): + + # First, try to just do a git-based update, which will give the most accurate results: + if have_git and not NOGIT: + self.check_workbench(package) + if package.status() != AddonManagerRepo.UpdateStatus.CANNOT_CHECK: + # It worked, just exit now + return + + # If we were unable to do a git-based update, try using the package.xml file instead: installed_metadata_file = os.path.join(clonedir, "package.xml") if not os.path.isfile(installed_metadata_file): # If there is no package.xml file, then it's because the package author added it after the last time @@ -1020,7 +1041,9 @@ class InstallWorkbenchWorker(QtCore.QThread): if self.repo.git_lock.locked(): FreeCAD.Console.PrintMessage("Waiting for lock to be released to us...\n") if not self.repo.git_lock.acquire(timeout=2): - FreeCAD.Console.PrintError("Timeout waiting for a lock on the git process, failed to clone repo\n") + FreeCAD.Console.PrintError( + "Timeout waiting for a lock on the git process, failed to clone repo\n" + ) return else: self.repo.git_lock.release() diff --git a/src/Mod/AddonManager/change_branch.py b/src/Mod/AddonManager/change_branch.py new file mode 100644 index 0000000000..7cca58a274 --- /dev/null +++ b/src/Mod/AddonManager/change_branch.py @@ -0,0 +1,284 @@ +# -*- coding: utf-8 -*- + +# *************************************************************************** +# * * +# * Copyright (c) 2022 Chris Hennes * +# * * +# * This library is free software; you can redistribute it and/or * +# * modify it under the terms of the GNU Lesser General Public * +# * License as published by the Free Software Foundation; either * +# * version 2.1 of the License, or (at your option) any later version. * +# * * +# * This library 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 * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with this library; if not, write to the Free Software * +# * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA * +# * 02110-1301 USA * +# * * +# *************************************************************************** + +import os + +import FreeCAD +import FreeCADGui + +from PySide2 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): + super().__init__(parent) + + self.ui = FreeCADGui.PySideUic.loadUi( + os.path.join(os.path.dirname(__file__), "change_branch.ui") + ) + + self.item_filter = ChangeBranchDialogFilter() + self.ui.tableView.setModel(self.item_filter) + + self.item_model = ChangeBranchDialogModel(path, self) + self.item_filter.setSourceModel(self.item_model) + self.ui.tableView.sortByColumn( + 4, QtCore.Qt.DescendingOrder + ) # Default to sorting by remote last-changed date + + # Figure out what row gets selected: + row = 0 + current_ref = self.item_model.repo.head.ref + selection_model = self.ui.tableView.selectionModel() + for ref in self.item_model.refs: + if ref == 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 + + # Make sure the column widths are OK: + header = self.ui.tableView.horizontalHeader() + header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents) + + def exec(self): + if self.ui.exec() == QtWidgets.QDialog.Accepted: + + selection = self.ui.tableView.selectedIndexes() + index = self.item_filter.mapToSource(selection[0]) + ref = self.item_model.data(index, ChangeBranchDialogModel.RefAccessRole) + + if ref == self.item_model.repo.head.ref: + # This is the one we are already on... just return + return + + if self.item_model.repo.is_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)?", + ), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel, + QtWidgets.QMessageBox.Cancel, + ) + 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() + else: + # It's already a local branch, just check it out + ref.checkout() + + self.branch_changed.emit(ref.name) + + +class ChangeBranchDialogModel(QtCore.QAbstractTableModel): + + refs = [] + display_data = [] + DataSortRole = QtCore.Qt.UserRole + RefAccessRole = QtCore.Qt.UserRole + 1 + + def __init__(self, path: os.PathLike, 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) + + def rowCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int: + if parent.isValid(): + return 0 + return len(self.refs) + + 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 + + def data(self, index: QtCore.QModelIndex, role: int = QtCore.Qt.DisplayRole): + if not index.isValid(): + return None + row = index.row() + column = index.column() + if role == QtCore.Qt.ToolTipRole: + tooltip = "" + # TODO: What should the tooltip be for these items? Last commit message? + 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.fromTime_t(dd[column]) + return QtCore.QLocale().toString(qdate, QtCore.QLocale.ShortFormat) + elif column < len(dd): + return dd[column] + 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] + else: + return None + elif role == ChangeBranchDialogModel.RefAccessRole: + return self.refs[row] + + def headerData( + self, + section: int, + orientation: QtCore.Qt.Orientation, + role: int = QtCore.Qt.DisplayRole, + ): + if orientation == QtCore.Qt.Vertical: + return None + if role != QtCore.Qt.DisplayRole: + return None + if section == 0: + return translate( + "AddonsInstaller", + "Kind", + "Table header for git ref type (e.g. either Tag or Branch)", + ) + 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", + ) + else: + return None + + +class ChangeBranchDialogFilter(QtCore.QSortFilterProxyModel): + def lessThan(self, left: QtCore.QModelIndex, right: QtCore.QModelIndex): + leftData = self.sourceModel().data(left, ChangeBranchDialogModel.DataSortRole) + rightData = self.sourceModel().data(right, ChangeBranchDialogModel.DataSortRole) + if leftData is None or rightData is None: + if rightData is not None: + return True + else: + return False + return leftData < rightData diff --git a/src/Mod/AddonManager/change_branch.ui b/src/Mod/AddonManager/change_branch.ui new file mode 100644 index 0000000000..c7cd0e7bc6 --- /dev/null +++ b/src/Mod/AddonManager/change_branch.ui @@ -0,0 +1,105 @@ + + + change_branch + + + + 0 + 0 + 550 + 300 + + + + Change Branch + + + true + + + + + + Change to branch or tag: + + + true + + + + + + + QAbstractItemView::NoEditTriggers + + + false + + + true + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + true + + + true + + + true + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + change_branch + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + change_branch + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/Mod/AddonManager/package_details.py b/src/Mod/AddonManager/package_details.py index 48c4e07dd8..40627cfb39 100644 --- a/src/Mod/AddonManager/package_details.py +++ b/src/Mod/AddonManager/package_details.py @@ -29,11 +29,22 @@ from PySide2.QtWidgets import * import os import FreeCAD +import FreeCADGui import addonmanager_utilities as utils from addonmanager_workers import GetMacroDetailsWorker, CheckSingleUpdateWorker from AddonManagerRepo import AddonManagerRepo import NetworkManager +from change_branch import ChangeBranchDialog + +have_git = False +try: + import git + + if hasattr(git, "Repo"): + have_git = True +except ImportError: + pass from typing import Optional @@ -83,6 +94,7 @@ class PackageDetails(QWidget): self.ui.buttonCheckForUpdate.clicked.connect( lambda: self.check_for_update.emit(self.repo) ) + self.ui.buttonChangeBranch.clicked.connect(self.change_branch_clicked) if HAS_QTWEBENGINE: self.ui.webView.loadStarted.connect(self.load_started) self.ui.webView.loadProgress.connect(self.load_progress) @@ -152,6 +164,7 @@ class PackageDetails(QWidget): def display_repo_status(self, status): repo = self.repo + self.set_change_branch_button_state() if status != AddonManagerRepo.UpdateStatus.NOT_INSTALLED: version = repo.installed_version @@ -187,7 +200,7 @@ class PackageDetails(QWidget): if repo.metadata: installed_version_string += ( "" - + translate("AddonsInstaller", "Update available to version") + + translate("AddonsInstaller", "On branch {}, update available to version").format(repo.branch) + " " ) installed_version_string += repo.metadata.Version @@ -210,10 +223,25 @@ class PackageDetails(QWidget): + "." ) elif status == AddonManagerRepo.UpdateStatus.NO_UPDATE_AVAILABLE: - installed_version_string += ( - translate("AddonsInstaller", "This is the latest version available") - + "." - ) + detached_head = False + branch = repo.branch + if have_git: + basedir = FreeCAD.getUserAppDataDir() + moddir = os.path.join(basedir, "Mod", repo.name) + gitrepo = git.Repo(moddir) + branch = gitrepo.head.ref.name + detached_head = gitrepo.head.is_detached + + if detached_head: + installed_version_string += ( + translate("AddonsInstaller", "Git tag '{}' checked out, no updates possible").format(branch) + + "." + ) + else: + installed_version_string += ( + translate("AddonsInstaller", "This is the latest version available for branch {}").format(branch) + + "." + ) elif status == AddonManagerRepo.UpdateStatus.PENDING_RESTART: installed_version_string += ( translate( @@ -347,6 +375,34 @@ class PackageDetails(QWidget): return first_supported_version return None + def set_change_branch_button_state(self): + """The change branch button is only available for installed Addons that have a .git directory + and in runs where the GitPython import is available.""" + + self.ui.buttonChangeBranch.hide() + + # Is this repo installed? If not, return. + if self.repo.status() == AddonManagerRepo.UpdateStatus.NOT_INSTALLED: + return + + # Is it a Macro? If so, return: + if self.repo.repo_type == AddonManagerRepo.RepoType.MACRO: + return + + # Can we actually switch branches? If not, return. + if not have_git: + return + + # Is there a .git subdirectory? If not, return. + basedir = FreeCAD.getUserAppDataDir() + path_to_git = os.path.join(basedir, "Mod", self.repo.name, ".git") + if not os.path.isdir(path_to_git): + return + + # 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 + self.ui.buttonChangeBranch.show() + def show_workbench(self, repo: AddonManagerRepo) -> None: """loads information of a given workbench""" url = utils.get_readme_html_url(repo) @@ -492,6 +548,47 @@ class PackageDetails(QWidget): html = f"

{m}

" self.ui.webView.setHtml(html) + def change_branch_clicked(self) -> None: + basedir = FreeCAD.getUserAppDataDir() + path_to_repo = os.path.join(basedir, "Mod", self.repo.name) + change_branch_dialog = ChangeBranchDialog(path_to_repo, self) + change_branch_dialog.branch_changed.connect(self.branch_changed) + change_branch_dialog.exec() + + def branch_changed(self, name: str) -> None: + QMessageBox.information( + self, + translate("AddonsInstaller", "Success"), + translate( + "AddonsInstaller", + "Branch change succeeded, please restart to use the new version.", + ), + ) + # See if this branch has a package.xml file: + basedir = FreeCAD.getUserAppDataDir() + path_to_metadata = os.path.join(basedir, "Mod", self.repo.name, "package.xml") + if os.path.isfile(path_to_metadata): + self.repo.load_metadata_file(path_to_metadata) + self.repo.installed_version = self.repo.metadata.Version + else: + self.repo.repo_type = AddonManagerRepo.RepoType.WORKBENCH + self.repo.metadata = None + self.repo.installed_version = None + self.repo.updated_timestamp = QDateTime.currentDateTime().toSecsSinceEpoch() + self.repo.branch = name + self.repo.set_status(AddonManagerRepo.UpdateStatus.PENDING_RESTART) + + installed_version_string = "

" + installed_version_string += translate( + "AddonsInstaller", "Changed to git ref '{}' -- please restart to use Addon." + ).format(name) + installed_version_string += "

" + self.ui.labelPackageDetails.setText(installed_version_string) + self.ui.labelPackageDetails.setStyleSheet( + "color:" + utils.attention_color_string() + ) + self.update_status.emit(self.repo) + if HAS_QTWEBENGINE: @@ -570,6 +667,11 @@ class Ui_PackageDetails(object): self.layoutDetailsBackButton.addWidget(self.buttonCheckForUpdate) + self.buttonChangeBranch = QPushButton(PackageDetails) + self.buttonChangeBranch.setObjectName("buttonChangeBranch") + + self.layoutDetailsBackButton.addWidget(self.buttonChangeBranch) + self.buttonExecute = QPushButton(PackageDetails) self.buttonExecute.setObjectName("buttonExecute") @@ -663,6 +765,9 @@ class Ui_PackageDetails(object): self.buttonExecute.setText( QCoreApplication.translate("AddonsInstaller", "Run Macro", None) ) + self.buttonChangeBranch.setText( + QCoreApplication.translate("AddonsInstaller", "Change Branch", None) + ) self.buttonBack.setToolTip( QCoreApplication.translate( "AddonsInstaller", "Return to package list", None diff --git a/src/Mod/AddonManager/package_details.ui b/src/Mod/AddonManager/package_details.ui index c9139ecd29..d7b060b90e 100644 --- a/src/Mod/AddonManager/package_details.ui +++ b/src/Mod/AddonManager/package_details.ui @@ -74,6 +74,13 @@ + + + + Change branch + + + diff --git a/src/Mod/AddonManager/package_list.py b/src/Mod/AddonManager/package_list.py index 287dd6d973..adf288e959 100644 --- a/src/Mod/AddonManager/package_list.py +++ b/src/Mod/AddonManager/package_list.py @@ -353,7 +353,9 @@ class PackageListItemDelegate(QStyledItemDelegate): ) elif len(maintainers) > 1: n = len(maintainers) - maintainers_string = translate("AddonsInstaller", "Maintainers:", "", n) + maintainers_string = translate( + "AddonsInstaller", "Maintainers:", "", n + ) for maintainer in maintainers: maintainers_string += ( f"\n{maintainer['name']} <{maintainer['email']}>"