Addon Manager: Complete migration away from GitPython

This commit is contained in:
Chris Hennes
2024-01-27 08:46:33 -06:00
committed by Chris Hennes
parent f301b763f6
commit c622e6ca42
4 changed files with 176 additions and 141 deletions

View File

@@ -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

View File

@@ -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):

View File

@@ -20,7 +20,7 @@
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Change to branch or tag:</string>
<string>Change to branch:</string>
</property>
<property name="wordWrap">
<bool>true</bool>

View File

@@ -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):