From ee162ff3a836e2db194455debde245efaaa06cb0 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Thu, 18 Aug 2022 09:49:37 -0500 Subject: [PATCH] Addon Manager: Refactor to test git and eliminate GitPython --- .../AddonManagerTest/app/test_git.py | 43 +++++- .../AddonManagerTest/data/test_repo.zip | Bin 22966 -> 23052 bytes .../gui/test_workers_startup.py | 57 +++++++- src/Mod/AddonManager/addonmanager_git.py | 116 +++++++++++++-- .../addonmanager_workers_installation.py | 85 ++++------- .../addonmanager_workers_startup.py | 137 +++++++++--------- 6 files changed, 295 insertions(+), 143 deletions(-) diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_git.py b/src/Mod/AddonManager/AddonManagerTest/app/test_git.py index 7ad29344ed..46bc937631 100644 --- a/src/Mod/AddonManager/AddonManagerTest/app/test_git.py +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_git.py @@ -26,6 +26,7 @@ import os import shutil import stat import tempfile +import time from zipfile import ZipFile import FreeCAD @@ -40,6 +41,7 @@ class TestGit(unittest.TestCase): def setUp(self): """Set up the test case: called by the unit test system""" + self.cwd = os.getcwd() test_data_dir = os.path.join( FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data" ) @@ -49,7 +51,9 @@ class TestGit(unittest.TestCase): ) os.makedirs(self.test_dir, exist_ok=True) self.test_repo_remote = os.path.join(self.test_dir, "TEST_REPO_REMOTE") - self._rmdir(self.test_repo_remote) + if os.path.exists(self.test_repo_remote): + # Make sure any old copy that got left around is deleted + self._rmdir(self.test_repo_remote) if not os.path.exists(git_repo_zip): self.skipTest("Can't find test repo") @@ -66,13 +70,16 @@ class TestGit(unittest.TestCase): def tearDown(self): """Clean up after the test""" - # self._rmdir(self.test_dir) + os.chdir(self.cwd) + #self._rmdir(self.test_dir) + os.rename(self.test_dir, self.test_dir + ".old." + str(time.time())) def test_clone(self): """Test git clone""" checkout_dir = self._clone_test_repo() self.assertTrue(os.path.exists(checkout_dir)) self.assertTrue(os.path.exists(os.path.join(checkout_dir, ".git"))) + self.assertEqual(os.getcwd(),self.cwd, "We should be left in the same CWD we started") def test_checkout(self): """Test git checkout""" @@ -82,6 +89,7 @@ class TestGit(unittest.TestCase): status = self.git.status(checkout_dir).strip() expected_status = "## HEAD (no branch)" self.assertEqual(status, expected_status) + self.assertEqual(os.getcwd(),self.cwd, "We should be left in the same CWD we started") def test_update(self): """Test using git to update the local repo""" @@ -91,6 +99,7 @@ class TestGit(unittest.TestCase): self.assertTrue(self.git.update_available(checkout_dir)) self.git.update(checkout_dir) self.assertFalse(self.git.update_available(checkout_dir)) + self.assertEqual(os.getcwd(),self.cwd, "We should be left in the same CWD we started") def test_tag_and_branch(self): """Test checking the currently checked-out tag""" @@ -108,12 +117,40 @@ class TestGit(unittest.TestCase): self.assertEqual(found_branch, expected_branch) self.assertFalse(self.git.update_available(checkout_dir)) - expected_branch = "master" + expected_branch = "main" self.git.checkout(checkout_dir, expected_branch) found_branch = self.git.current_branch(checkout_dir) self.assertEqual(found_branch, expected_branch) self.assertFalse(self.git.update_available(checkout_dir)) + self.assertEqual(os.getcwd(),self.cwd, "We should be left in the same CWD we started") + + def test_get_remote(self): + """ Test getting the remote location """ + checkout_dir = self._clone_test_repo() + expected_remote = self.test_repo_remote + returned_remote = self.git.get_remote(checkout_dir) + self.assertEqual(expected_remote, returned_remote) + self.assertEqual(os.getcwd(),self.cwd, "We should be left in the same CWD we started") + + def test_repair(self): + """ Test the repair feature (and some exception throwing) """ + checkout_dir = self._clone_test_repo() + remote = self.git.get_remote(checkout_dir) + git_dir = os.path.join(checkout_dir,".git") + self.assertTrue(os.path.exists(git_dir)) + self._rmdir(git_dir) + + # Make sure that we've truly broken the install + with self.assertRaises(GitFailed): + self.git.status(checkout_dir) + + self.git.repair(remote, checkout_dir) + status = self.git.status(checkout_dir) + self.assertEqual(status,"## main...origin/main\n") + self.assertEqual(os.getcwd(),self.cwd, "We should be left in the same CWD we started") + + def _rmdir(self, path): try: shutil.rmtree(path, onerror=self._remove_readonly) diff --git a/src/Mod/AddonManager/AddonManagerTest/data/test_repo.zip b/src/Mod/AddonManager/AddonManagerTest/data/test_repo.zip index c35876021c3c6af263a049652b648a3be2540de2..6449777ab5191a7658e2be93225ad50a2bd45ff4 100644 GIT binary patch delta 1921 zcmZux2~<-@6#f5W6d@ZVAt4}wBo@9xhy)Y_*@G-0fe?})Vi9am7Fo66f~fI;rL{P= zXkC#awMbEjsGuT7u}Xz{j*2Td1rEG?zA+c6$X^1 zh%y_;Mu;yy%DR%4Z5A74eQ5P~lm>rQhf~wk97u6MC5ltBQWj@hfF5^#^IbG@It|vV zY8OhHR>6XDFe?Q6&2W_)6+cVpJwZ9DO>YK`KjvPDDF5;F9 zZY}lv_R6KRy(-a6y_4YEE1a)lHg_mG)(o6KNt=hSeHgv8#HDyp_`0iIex`W1C^R$4 z>e=OjKI7W9MY@SKE7p(VvCZA?DwIy_u{5RekOV~#i~__S4kd@d#TYXu=DD6>*IAE) zW>23!`D1Zs@=9dE8hv%tlcwC)2zZ+f{c69|~etd``u(rcR?Y@OR$)c0zvj4)eE{=CXj*iz| zdp{@gb@?IR!si{wDv}&{D-4_Z^Q-MUtC%y-)a5#^b9E|@b}l;QcU#EH8nY`FQi2EU zK}X~33ruz;jKt$8Z!3mQ~#QwzrNSEuuz=O<75az)OYZem!He-pXhsTF*YP* zcyCW`X1wz6-zcc~AW$vcUFFZOsyY{*caNGo$hz7w+HP4=-_Ln_3!tRnpwtmK$|;~h zKEd*@+d<^li+!yL$vs<7TXw@!=VJ)sjDT3V4d|L-Swh^8ouBw;8$GD{hY#5HH>+t` z7hX-=xHtXtJ-5nzX%| z0_%0NZOi+7X2p1Uy}TS}=*ZZy`imEh67H4ah}8SVS-maktG5k|(0ns474LOA2usDZ9*Y}MS`tcf98Khf>k`vb*F^utM zw)Y+XkWo|DM|qX{=BuQ81#7<6H(&U=u4VU@+NYk4%Q>HqT#a7zg4Vokh6RhT1ZePg z1m8<3peu}NMQC7IcW0L?)PNWc;G~obGBxJd;_POI)DJE3ffa3SzAn2Udkqd290iN= z3l`)p%2_Ik3kwRBg}G;?$zsIx(;m#5)1EV=+WOJbN_2dD`N$22i%xm_^VIRH<*iOy zhs>^J=Dch6%aikO8ZI)=DuO&B2DTq6tBGgzB;EDGTTeOn`}JsZg|+&qO&>XL?s#d` ztNM7BkJ#5+TXGW*m_4d+trKMXXKQUoiiQM&sSU%usSVHQPe1BS2pDw#Y;4)<{uFam zcg>qGch@qWIyq#dFE0yzbJ;sU5rtxG?jw&>w?i zKZpnqOnWPrIYL~Kw=WZd@Q6nXxawYIfMTXB@oX)ZOQ=`G*oav#bj_zJ>blQ<7bc(%LmUCI3|z) zD~Ubuj>WMg0tSOGA(kGBp$~ML7+Ow9;}4$2?2w_V+hv-ll3B%8+OzLm#dW6=@2se zX$S>8R=HrWOrcwX68!{}CWND%aGnIJLZWBm?af2Nr>j?ff*sKK$Frpi{0FJ>MN7fk{ztqT@FKs%t(vL?UF X^@PZ#L*EBBYNuF~S|Lc`Tq64iV=1a> delta 1737 zcmZXVc~DbV6o>O}ARtLFEMrL60;57Qgs>_oTQDq=uy2Wo1`)&sK@dfdX6k4`8*w=( zg0ksQgeepR9j2gAWK=qepcNHS*+dH>RgeKi?aPC9YW?Hg_r0^+x#!&byHDAGU8%=l z!k&GC9YgNRV$^*{!RXcsMtDWaes@C>iDU>TFrHReTloRy{sk7!Oi z_yDY|9TTpn7XR-zLb29x7Dq5!QQ#3>JJ^oAs{x zIx-1bF*bY@=)pvQM*Syi4yLPGhVL1r#an!#4TwZCAi>+fB?@0Jp{S_)PJo&j zjnrTNQQvh`7)qfWvAxmAG;L+dOJ6gXHn$TlB?-FEBn-y%6chNrJIZvbnCh~N+uT}I;?7+BOXZT0J(AGW@rHA!PfhwKW@j?i zU0BZbp+0mn-!M~{s#rH*cdB9e-sWEqeR`PtDA8MU^QBjoQwNqO%lYLZyFZTkc)6rk zAAtQe5d%r%i-$&f&I*dRhmAaOs#jDdMHh@^@-%X)<8RftZ>^0;ABoG_Q8P2^o?%zG zOSwkhJYCsa)s?-bz4Hi`8UMj$@NtHn;!AB`7AZ(fR@28DL4r|%4&>Wfknw$l-)vXu zvhX%pQFeVkk2^y!5^0&LO@>e!q^U^V9hXwn*`(Fx!BFp7*Rn~>&wG5^%}eAvl=M2* zf!g7{=l<{ny*Gh*Zq$xxts#GEYwY4=X85jI=d@Ku_JN>-7d>vu-$%F08{!iqL*iEc zUcg(O{l>WFu0#C!dlnnl7mTg9VCTl}TW=7ll_$(QZR~+|v^mkXZfeTApSQln!o6;K za^T4WP3_HAPI~!5W9~^$kxj4FW&IxtOGUeVzid;V+t#8KT(Z~FgIRk9xH;%SilBsizh*p)y>)Wbd#YTfku1I_oIF~5eAec@;?=XJ zxw0uLV8KRk!Z%%|NuaIG`JX1s)`p-P7ziayp!xC8>^uhgxg#l=uvYl3Mxah$t2zrB z{TQ%R%tjw*xXA#d;?<~wj-w&e;>cc1pm;GIk|YdRCShZv;31JAdwtx&hRYHUw1r8; z==wXNcs7CgY&vWeGa%2LK~o(&jKsl5r8(ieYOYNM|EIj8h{tzb@f0Kw=IB+&Vh}{L#KqgED@DL^dPM{BZDNyMf6oE9(x6&t)7goWt zQ3a~0E&^~3Gy)fyHs%3;1zHlCFbp!p_TpbgxCAI53!<5RtC0WB*02z(sYV>IsvsUJ zuv?H21zhd5fFq*;J=g^0*n@`y3%rAQ=&A}$;CQeldaptw=m`!&G)Is-8-XZl#goXu{tyqmAVpE!!SO2G>^O8xS$N1Z!Ov2Yh1T(~3s5a(!X}vj>m{gCW?=f2dhwa`zM4%4{W5c{Lpt~m I%2(z18(ztNJ^%m! diff --git a/src/Mod/AddonManager/AddonManagerTest/gui/test_workers_startup.py b/src/Mod/AddonManager/AddonManagerTest/gui/test_workers_startup.py index e92007e852..90e1d7a3e5 100644 --- a/src/Mod/AddonManager/AddonManagerTest/gui/test_workers_startup.py +++ b/src/Mod/AddonManager/AddonManagerTest/gui/test_workers_startup.py @@ -26,6 +26,13 @@ import json import unittest import os import tempfile + +have_git = True +try: + import git +except ImportError: + have_git = False + import FreeCAD from PySide2 import QtCore @@ -36,6 +43,11 @@ from addonmanager_workers_startup import ( CreateAddonListWorker, LoadPackagesFromCacheWorker, LoadMacrosFromCacheWorker, + CheckSingleUpdateWorker, + ) + +from addonmanager_workers_installation import ( + InstallWorkbenchWorker, ) class TestWorkersStartup(unittest.TestCase): @@ -159,8 +171,49 @@ class TestWorkersStartup(unittest.TestCase): def test_update_checker(self): """ Test the code that checks a single addon for available updates. """ - # First, install a specific Addon of each kind + if not have_git: + return + + # Populate the test's Addon List: + self.test_create_addon_list_worker() + + # First, install a specific Addon of each kind into a temp location + location = os.path.join(tempfile.gettempdir(),"FreeCADTesting") + + self._test_workbench_update_checker(location) + + + + # Preference Pack + # Macro # Arrange for those addons to be out-of-date - # Check for updates \ No newline at end of file + # Check for updates + + def _test_workbench_update_checker(self, location): + + # Workbench: use the FreeCAD-Help workbench for testing purposes + help_addon = None + for addon in self.addon_list: + if addon.name == "Help": + help_addon = addon + break + if not help_addon: + print("Unable to locate the FreeCAD-Help addon to test with") + return + + addon_location = os.path.join(location, help_addon.name) + worker = InstallWorkbenchWorker(addon, addon_location) + worker.run() # Synchronous call, blocks until complete + gitrepo = git.Git(addon_location) + gitrepo.reset("--hard", "HEAD^") + print (addon_location) + + # At this point the addon should be "out of date", checked out to one revision behind + # the most recent. + + worker = CheckSingleUpdateWorker(help_addon) + worker.do_work() # Synchronous call + + self.assertEqual(help_addon.status(), Addon.Status.UPDATE_AVAILABLE) \ No newline at end of file diff --git a/src/Mod/AddonManager/addonmanager_git.py b/src/Mod/AddonManager/addonmanager_git.py index b4e4c98ef9..e297457b3e 100644 --- a/src/Mod/AddonManager/addonmanager_git.py +++ b/src/Mod/AddonManager/addonmanager_git.py @@ -29,8 +29,11 @@ import platform import shutil import subprocess from typing import List +import time import FreeCAD +translate = FreeCAD.Qt.translate + class NoGitFound(RuntimeError): """Could not locate the git executable on this system.""" @@ -80,15 +83,47 @@ class GitManager: old_dir = os.getcwd() os.chdir(local_path) self._synchronous_call_git(["fetch"]) - self._synchronous_call_git(["pull"]) - self._synchronous_call_git(["submodule", "update", "--init", "--recursive"]) + try: + self._synchronous_call_git(["pull"]) + self._synchronous_call_git(["submodule", "update", "--init", "--recursive"]) + except GitFailed as e: + FreeCAD.Console.PrintWarning( + translate( + "AddonsInstaller", + "Basic git update failed with the following message:", + ) + + str(e) + + "\n" + ) + FreeCAD.Console.PrintWarning( + translate( + "AddonsInstaller", + "Backing up the original directory and re-cloning", + ) + + "...\n" + ) + remote = self.get_remote(local_path) + with open(os.path.join(local_path, "ADDON_DISABLED"), "w") as f: + f.write( + "This is a backup of an addon that failed to update cleanly so was re-cloned. " + + "It was disabled by the Addon Manager's git update facility and can be safely " + + "deleted if the addon is working properly." + ) + os.chdir("..") + os.rename(local_path, local_path + ".backup" + str(time.time())) + self.clone(remote, local_path) os.chdir(old_dir) def status(self, local_path) -> str: """Gets the v1 porcelain status""" old_dir = os.getcwd() os.chdir(local_path) - status = self._synchronous_call_git(["status", "-sb", "--porcelain"]) + try: + status = self._synchronous_call_git(["status", "-sb", "--porcelain"]) + except GitFailed as e: + os.chdir(old_dir) + raise e + os.chdir(old_dir) return status @@ -99,7 +134,11 @@ class GitManager: final_args = ["reset"] if args: final_args.extend(args) - self._synchronous_call_git(final_args) + try: + self._synchronous_call_git(final_args) + except GitFailed as e: + os.chdir(old_dir) + raise e os.chdir(old_dir) def async_fetch_and_update(self, local_path, progress_monitor, args=None): @@ -109,8 +148,12 @@ class GitManager: """Returns True if an update is available from the remote, or false if not""" old_dir = os.getcwd() os.chdir(local_path) - self._synchronous_call_git(["fetch"]) - status = self._synchronous_call_git(["status", "-sb", "--porcelain"]) + try: + self._synchronous_call_git(["fetch"]) + status = self._synchronous_call_git(["status", "-sb", "--porcelain"]) + except GitFailed as e: + os.chdir(old_dir) + raise e os.chdir(old_dir) return "behind" in status @@ -118,7 +161,11 @@ class GitManager: """Get the name of the currently checked-out tag if HEAD is detached""" old_dir = os.getcwd() os.chdir(local_path) - tag = self._synchronous_call_git(["describe", "--tags"]).strip() + try: + tag = self._synchronous_call_git(["describe", "--tags"]).strip() + except GitFailed as e: + os.chdir(old_dir) + raise e os.chdir(old_dir) return tag @@ -126,7 +173,11 @@ class GitManager: """Get the name of the current branch""" old_dir = os.getcwd() os.chdir(local_path) - branch = self._synchronous_call_git(["branch", "--show-current"]).strip() + try: + branch = self._synchronous_call_git(["branch", "--show-current"]).strip() + except GitFailed as e: + os.chdir(old_dir) + raise e os.chdir(old_dir) return branch @@ -135,14 +186,55 @@ class GitManager: ensures that it is. Note that any local changes in local_path will be destroyed. This is achieved by archiving the old path, cloning an entirely new copy, and then deleting the old directory.""" - old_path = local_path + ".bak" - os.rename(local_path, old_path) + + original_cwd = os.getcwd() + + # Make sure we are not currently in that directory, otherwise on Windows the rename + # will fail. To guarantee we aren't in it, change to it, then shift up one. + os.chdir(local_path) + os.chdir("..") + backup_path = local_path + ".backup" + str(time.time()) + os.rename(local_path, backup_path) try: self.clone(remote, local_path) except GitFailed as e: - shutil.rmtree(local_path) - os.rename(old_path, local_path) + FreeCAD.Console.PrintError( + translate( + "AddonsInstaller", "Failed to clone {} into {} using git" + ).format(remote, local_path) + ) + os.chdir(original_cwd) raise e + os.chdir(original_cwd) + + def get_remote(self, local_path) -> str: + """Get the repository that this local path is set to fetch from""" + old_dir = os.getcwd() + os.chdir(local_path) + try: + response = self._synchronous_call_git(["remote", "-v", "show"]) + except GitFailed as e: + os.chdir(old_dir) + raise e + lines = response.split("\n") + result = "(unknown remote)" + for line in lines: + if line.endswith("(fetch)"): + + # The line looks like: + # origin https://some/sort/of/path (fetch) + + segments = line.split() + if len(segments) == 3: + result = segments[1] + break + else: + FreeCAD.Console.PrintWarning( + "Error parsing the results from git remote -v show:\n" + ) + FreeCAD.Console.PrintWarning(line + "\n") + os.chdir(old_dir) + return result def _find_git(self): # Find git. In preference order diff --git a/src/Mod/AddonManager/addonmanager_workers_installation.py b/src/Mod/AddonManager/addonmanager_workers_installation.py index b8e11e4d16..a683f22546 100644 --- a/src/Mod/AddonManager/addonmanager_workers_installation.py +++ b/src/Mod/AddonManager/addonmanager_workers_installation.py @@ -49,22 +49,17 @@ import addonmanager_utilities as utils from addonmanager_macro import Macro from Addon import Addon import NetworkManager +from addonmanager_git import GitManager, GitFailed, NoGitFound -have_git = False -try: - import git - # Some types of Python installation will fall back to finding a directory called "git" - # in certain locations instead of a Python package called git: that directory is unlikely - # to have the "Repo" attribute unless it is a real installation, however, so this check - # should catch that. (Bug #4072) - have_git = hasattr(git, "Repo") - if not have_git: - FreeCAD.Console.PrintMessage( - "Unable to locate a viable GitPython installation: falling back to ZIP installation." - ) -except ImportError: - pass +git_manager = None +pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") +disable_git = pref.GetBool("disableGit", False) +if not disable_git: + try: + git_manager = GitManager() + except NoGitFound: + pass # A log messsage was already printed by the startup code translate = FreeCAD.Qt.translate @@ -73,8 +68,6 @@ translate = FreeCAD.Qt.translate # \brief Multithread workers for the addon manager # @{ -NOGIT = False # for debugging purposes, set this to True to always use http downloads - class InstallWorkbenchWorker(QtCore.QThread): "This worker installs a workbench" @@ -84,7 +77,7 @@ class InstallWorkbenchWorker(QtCore.QThread): success = QtCore.Signal(Addon, str) failure = QtCore.Signal(Addon, str) - def __init__(self, repo: Addon): + def __init__(self, repo: Addon, location=None): QtCore.QThread.__init__(self) self.repo = repo @@ -93,13 +86,22 @@ class InstallWorkbenchWorker(QtCore.QThread): self.update_timer.timeout.connect(self.update_status) self.update_timer.start() + if location: + self.clone_directory = location + else: + basedir = FreeCAD.getUserAppDataDir() + self.clone_directory = os.path.join(basedir, "Mod", repo.name) + + if not os.path.exists(self.clone_directory): + os.makedirs(self.clone_directory) + def run(self): "installs or updates the selected addon" if not self.repo: return - if not have_git or NOGIT: + if not git_manager: FreeCAD.Console.PrintLog( translate( "AddonsInstaller", @@ -117,13 +119,9 @@ class InstallWorkbenchWorker(QtCore.QThread): ) return - basedir = FreeCAD.getUserAppDataDir() - moddir = basedir + os.sep + "Mod" - if not os.path.exists(moddir): - os.makedirs(moddir) - target_dir = moddir + os.sep + self.repo.name + target_dir = self.clone_directory - if have_git and not NOGIT: + if git_manager: # Do the git process... self.run_git(target_dir) else: @@ -144,7 +142,7 @@ class InstallWorkbenchWorker(QtCore.QThread): def run_git(self, clonedir: str) -> None: - if NOGIT or not have_git: + if not git_manager: FreeCAD.Console.PrintLog( translate( "AddonsInstaller", @@ -165,10 +163,9 @@ class InstallWorkbenchWorker(QtCore.QThread): self.status_message.emit("Updating module...") with self.repo.git_lock: if not os.path.exists(clonedir + os.sep + ".git"): - utils.repair_git_repo(self.repo.url, clonedir) - repo = git.Git(clonedir) + git_manager.repair(self.repo.url, clonedir) try: - repo.pull("--ff-only") # Refuses to take a progress object? + git_manager.update(clonedir) if self.repo.contains_workbench(): answer = translate( "AddonsInstaller", @@ -179,7 +176,7 @@ class InstallWorkbenchWorker(QtCore.QThread): "AddonsInstaller", "Workbench successfully updated.", ) - except Exception as e: + except GitFailed as e: answer = ( translate("AddonsInstaller", "Error updating module") + " " @@ -190,14 +187,8 @@ class InstallWorkbenchWorker(QtCore.QThread): ) answer += str(e) self.failure.emit(self.repo, answer) - else: - # Update the submodules for this repository - repo_sms = git.Repo(clonedir) - self.status_message.emit("Updating submodules...") - for submodule in repo_sms.submodules: - submodule.update(init=True, recursive=True) - self.update_metadata() - self.success.emit(self.repo, answer) + self.update_metadata() + self.success.emit(self.repo, answer) def run_git_clone(self, clonedir: str) -> None: self.status_message.emit("Cloning module...") @@ -216,27 +207,14 @@ class InstallWorkbenchWorker(QtCore.QThread): with self.repo.git_lock: FreeCAD.Console.PrintMessage("Lock acquired...\n") - # NOTE: There is no way to interrupt this process in GitPython: someday we should - # support pygit2/libgit2 so we can actually interrupt this properly. - repo = git.Repo.clone_from( - self.repo.url, clonedir, progress=self.git_progress - ) + git_manager.clone(self.repo.url, clonedir) FreeCAD.Console.PrintMessage("Initial clone complete...\n") if current_thread.isInterruptionRequested(): return - # Make sure to clone all the submodules as well - if repo.submodules: - FreeCAD.Console.PrintMessage("Updating submodules...\n") - repo.submodule_update(recursive=True) - if current_thread.isInterruptionRequested(): return - if self.repo.branch in repo.heads: - FreeCAD.Console.PrintMessage("Checking out HEAD...\n") - repo.heads[self.repo.branch].checkout() - FreeCAD.Console.PrintMessage("Clone complete\n") if self.repo.contains_workbench(): @@ -728,13 +706,12 @@ class UpdateMetadataCacheWorker(QtCore.QThread): repo.cached_icon_filename = cache_file -if have_git and not NOGIT: +if git_manager: - class GitProgressMonitor(git.RemoteProgress): + class GitProgressMonitor: """An object that receives git progress updates and stores them for later display""" def __init__(self): - super().__init__() self.current = 0 self.total = 100 self.message = "" diff --git a/src/Mod/AddonManager/addonmanager_workers_startup.py b/src/Mod/AddonManager/addonmanager_workers_startup.py index 0ce0113275..012cbaf708 100644 --- a/src/Mod/AddonManager/addonmanager_workers_startup.py +++ b/src/Mod/AddonManager/addonmanager_workers_startup.py @@ -40,29 +40,25 @@ import addonmanager_utilities as utils from addonmanager_macro import Macro from Addon import Addon import NetworkManager - -have_git = False -try: - import git - - # Some types of Python installation will fall back to finding a directory called "git" - # in certain locations instead of a Python package called git: that directory is unlikely - # to have the "Repo" attribute unless it is a real installation, however, so this check - # should catch that. (Bug #4072) - have_git = hasattr(git, "Repo") - if not have_git: - FreeCAD.Console.PrintMessage( - "Unable to locate a viable GitPython installation: falling back to ZIP installation." - ) -except ImportError: - pass -NOGIT = False # for debugging purposes, set this to True to always use http downloads +from addonmanager_git import GitManager, GitFailed, NoGitFound translate = FreeCAD.Qt.translate +git_manager = None +pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") +disable_git = pref.GetBool("disableGit", False) +if not disable_git: + try: + git_manager = GitManager() + except NoGitFound: + FreeCAD.Console.PrintWarning( + translate("AddonsInstaller", "Could not locate a suitable git executable") + ) + # Workers only have one public method by design # pylint: disable=too-few-public-methods + class CreateAddonListWorker(QtCore.QThread): """This worker updates the list of available workbenches, emitting an "addon_repo" signal for each Addon as they are processed.""" @@ -133,7 +129,7 @@ class CreateAddonListWorker(QtCore.QThread): raise ConnectionError def _process_deprecated(self, deprecated_addons): - """ Parse the section on deprecated addons """ + """Parse the section on deprecated addons""" fc_major = int(FreeCAD.Version()[0]) fc_minor = int(FreeCAD.Version()[1]) @@ -146,9 +142,7 @@ class CreateAddonListWorker(QtCore.QThread): minor = int(version_components[1]) else: minor = 0 - if major < fc_major or ( - major == fc_major and minor <= fc_minor - ): + if major < fc_major or (major == fc_major and minor <= fc_minor): if "kind" not in item or item["kind"] == "mod": self.obsolete.append(item["name"]) elif item["kind"] == "macro": @@ -268,10 +262,10 @@ class CreateAddonListWorker(QtCore.QThread): macro_cache_location = utils.get_cache_file_name("Macros") - if not have_git or NOGIT: + if not git_manager: message = translate( "AddonsInstaller", - "Failed to execute Git Python command: check installation of GitPython and/or git", + "Failed to execute git command: check installation of git", ) self.status_message_signal.emit(message) FreeCAD.Console.PrintWarning(message + "\n") @@ -316,16 +310,17 @@ class CreateAddonListWorker(QtCore.QThread): "Attempting to change non-git Macro setup to use git\n", ) ) - utils.repair_git_repo( - "https://github.com/FreeCAD/FreeCAD-macros.git", macro_cache_location + git_manager.repair( + "https://github.com/FreeCAD/FreeCAD-macros.git", + macro_cache_location, ) - gitrepo = git.Git(macro_cache_location) - gitrepo.pull("--ff-only") + git_manager.update(macro_cache_location) else: - git.Repo.clone_from( - "https://github.com/FreeCAD/FreeCAD-macros.git", macro_cache_location + git_manager.clone( + "https://github.com/FreeCAD/FreeCAD-macros.git", + macro_cache_location, ) - except git.exc.GitError as e: + except GitFailed as e: FreeCAD.Console.PrintMessage( translate( "AddonsInstaller", @@ -340,13 +335,14 @@ class CreateAddonListWorker(QtCore.QThread): ) try: shutil.rmtree(macro_cache_location, onerror=self._remove_readonly) - git.Repo.clone_from( - "https://github.com/FreeCAD/FreeCAD-macros.git", macro_cache_location + git_manager.clone( + "https://github.com/FreeCAD/FreeCAD-macros.git", + macro_cache_location, ) FreeCAD.Console.PrintMessage( translate("AddonsInstaller", "Clean checkout succeeded") + "\n" ) - except git.exc.GitError as e2: + except GitFailed as e2: # The Qt Python translation extractor doesn't support splitting this string (yet) # pylint: disable=line-too-long FreeCAD.Console.PrintWarning( @@ -413,7 +409,8 @@ class CreateAddonListWorker(QtCore.QThread): class LoadPackagesFromCacheWorker(QtCore.QThread): - """ A subthread worker that loads package information from its cache file. """ + """A subthread worker that loads package information from its cache file.""" + addon_repo = QtCore.Signal(object) def __init__(self, cache_file: str): @@ -424,12 +421,12 @@ class LoadPackagesFromCacheWorker(QtCore.QThread): ) def override_metadata_cache_path(self, path): - """ For testing purposes, override the location to fetch the package metadata from. """ + """For testing purposes, override the location to fetch the package metadata from.""" self.metadata_cache_path = path def run(self): - """ Rarely called directly: create an instance and call start() on it instead to - launch in a new thread """ + """Rarely called directly: create an instance and call start() on it instead to + launch in a new thread""" with open(self.cache_file, "r", encoding="utf-8") as f: data = f.read() if data: @@ -456,7 +453,7 @@ class LoadPackagesFromCacheWorker(QtCore.QThread): class LoadMacrosFromCacheWorker(QtCore.QThread): - """ A worker object to load macros from a cache file """ + """A worker object to load macros from a cache file""" add_macro_signal = QtCore.Signal(object) @@ -465,8 +462,8 @@ class LoadMacrosFromCacheWorker(QtCore.QThread): self.cache_file = cache_file def run(self): - """ Rarely called directly: create an instance and call start() on it instead to - launch in a new thread """ + """Rarely called directly: create an instance and call start() on it instead to + launch in a new thread""" with open(self.cache_file, "r", encoding="utf-8") as f: data = f.read() @@ -491,8 +488,8 @@ class CheckSingleUpdateWorker(QtCore.QObject): self.repo = repo def do_work(self): - """ Use the UpdateChecker class to do the work of this function, depending on the - type of Addon """ + """Use the UpdateChecker class to do the work of this function, depending on the + type of Addon""" checker = UpdateChecker() if self.repo.repo_type == Addon.Kind.WORKBENCH: @@ -520,8 +517,8 @@ class CheckWorkbenchesForUpdatesWorker(QtCore.QThread): self.moddir = os.path.join(self.basedir, "Mod") def run(self): - """ Rarely called directly: create an instance and call start() on it instead to - launch in a new thread """ + """Rarely called directly: create an instance and call start() on it instead to + launch in a new thread""" self.current_thread = QtCore.QThread.currentThread() checker = UpdateChecker() @@ -544,22 +541,22 @@ class CheckWorkbenchesForUpdatesWorker(QtCore.QThread): class UpdateChecker: - """ A utility class used by the CheckWorkbenchesForUpdatesWorker class. Each function is + """A utility class used by the CheckWorkbenchesForUpdatesWorker class. Each function is designed for a specific Addon type, and modifies the passed-in Addon with the determined - update status. """ + update status.""" def __init__(self): self.basedir = FreeCAD.getUserAppDataDir() self.moddir = os.path.join(self.basedir, "Mod") def override_mod_directory(self, moddir): - """ Primarily for use when testing, sets an alternate directory to use for mods """ + """Primarily for use when testing, sets an alternate directory to use for mods""" self.moddir = moddir def check_workbench(self, wb): - """ Given a workbench Addon wb, check it for updates using git. If git is not - available, does nothing. """ - if not have_git or NOGIT: + """Given a workbench Addon wb, check it for updates using git. If git is not + available, does nothing.""" + if not git_manager: wb.set_status(Addon.Status.CANNOT_CHECK) return clonedir = os.path.join(self.moddir, wb.name) @@ -567,21 +564,17 @@ class UpdateChecker: # mark as already installed AND already checked for updates if not os.path.exists(os.path.join(clonedir, ".git")): with wb.git_lock: - utils.repair_git_repo(wb.url, clonedir) + git_manager.repair(wb.url, clonedir) with wb.git_lock: - gitrepo = git.Repo(clonedir) try: - if gitrepo.head.is_detached: + status = git_manager.status(clonedir) + if "(no branch)" in git_manager.status(clonedir): # By definition, in a detached-head state we cannot # update, so don't even bother checking. wb.set_status(Addon.Status.NO_UPDATE_AVAILABLE) - if hasattr(gitrepo.head, "ref"): - wb.branch = gitrepo.head.ref.name - else: - wb.branch = gitrepo.head.name + wb.branch = git_manager.current_branch(clonedir) return - gitrepo.git.fetch() - except git.exc.GitError as e: + except GitFailed as e: FreeCAD.Console.PrintWarning( "AddonManager: " + translate( @@ -594,11 +587,11 @@ class UpdateChecker: wb.set_status(Addon.Status.CANNOT_CHECK) else: try: - if "git pull" in gitrepo.git.status(): + if git_manager.update_available(clonedir): wb.set_status(Addon.Status.UPDATE_AVAILABLE) else: wb.set_status(Addon.Status.NO_UPDATE_AVAILABLE) - except git.exc.GitError: + except GitFailed: FreeCAD.Console.PrintWarning( translate( "AddonsInstaller", "git status failed for {}" @@ -608,7 +601,7 @@ class UpdateChecker: wb.set_status(Addon.Status.CANNOT_CHECK) def check_package(self, package: Addon) -> None: - """ Given a packaged Addon package, check it for updates. If git is available that is + """Given a packaged Addon package, check it for updates. If git is available that is used. If not, the package's metadata is examined, and if the metadata file has changed compared to the installed copy, an update is flagged.""" @@ -616,7 +609,7 @@ class UpdateChecker: 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: + if git_manager: self.check_workbench(package) if package.status() != Addon.Status.CANNOT_CHECK: # It worked, just exit now @@ -653,7 +646,7 @@ class UpdateChecker: package.set_status(Addon.Status.CANNOT_CHECK) def check_macro(self, macro_wrapper: Addon) -> None: - """ Check to see if the online copy of the macro's code differs from the local copy. """ + """Check to see if the online copy of the macro's code differs from the local copy.""" # Make sure this macro has its code downloaded: try: @@ -724,8 +717,8 @@ class CacheMacroCodeWorker(QtCore.QThread): self.repo_queue = None def run(self): - """ Rarely called directly: create an instance and call start() on it instead to - launch in a new thread """ + """Rarely called directly: create an instance and call start() on it instead to + launch in a new thread""" self.status_message.emit(translate("AddonsInstaller", "Caching macro code...")) @@ -769,8 +762,8 @@ class CacheMacroCodeWorker(QtCore.QThread): ) def _process_queue(self, num_macros) -> bool: - """ Spools up six network connections and downloads the macro code. Returns True if - it was interrupted by user request, or False if it ran to completion. """ + """Spools up six network connections and downloads the macro code. Returns True if + it was interrupted by user request, or False if it ran to completion.""" # Emulate QNetworkAccessManager and spool up six connections: for _ in range(6): @@ -800,7 +793,7 @@ class CacheMacroCodeWorker(QtCore.QThread): return False def update_and_advance(self, repo: Addon) -> None: - """ Emit the updated signal and launch the next item from the queue. """ + """Emit the updated signal and launch the next item from the queue.""" if repo is not None: if repo.macro.name not in self.failed: self.update_macro.emit(repo) @@ -835,7 +828,7 @@ class CacheMacroCodeWorker(QtCore.QThread): pass def terminate(self, worker) -> None: - """ Shut down all running workers and exit the thread """ + """Shut down all running workers and exit the thread""" if not worker.isFinished(): macro_name = worker.macro.name FreeCAD.Console.PrintWarning( @@ -871,8 +864,8 @@ class GetMacroDetailsWorker(QtCore.QThread): self.macro = repo.macro def run(self): - """ Rarely called directly: create an instance and call start() on it instead to - launch in a new thread """ + """Rarely called directly: create an instance and call start() on it instead to + launch in a new thread""" self.status_message.emit( translate("AddonsInstaller", "Retrieving macro description...")