FreeCAD
480 строк · 18.4 Кб
1# SPDX-License-Identifier: LGPL-2.1-or-later
2# ***************************************************************************
3# * *
4# * Copyright (c) 2022 FreeCAD Project Association *
5# * *
6# * This file is part of FreeCAD. *
7# * *
8# * FreeCAD is free software: you can redistribute it and/or modify it *
9# * under the terms of the GNU Lesser General Public License as *
10# * published by the Free Software Foundation, either version 2.1 of the *
11# * License, or (at your option) any later version. *
12# * *
13# * FreeCAD is distributed in the hope that it will be useful, but *
14# * WITHOUT ANY WARRANTY; without even the implied warranty of *
15# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
16# * Lesser General Public License for more details. *
17# * *
18# * You should have received a copy of the GNU Lesser General Public *
19# * License along with FreeCAD. If not, see *
20# * <https://www.gnu.org/licenses/>. *
21# * *
22# ***************************************************************************
23
24""" Wrapper around git executable to simplify calling git commands from Python. """
25
26# pylint: disable=too-few-public-methods
27
28import os29import platform30import shutil31import subprocess32from typing import List, Dict, Optional33import time34
35import addonmanager_utilities as utils36import addonmanager_freecad_interface as fci37
38translate = fci.translate39
40
41class NoGitFound(RuntimeError):42"""Could not locate the git executable on this system."""43
44
45class GitFailed(RuntimeError):46"""The call to git returned an error of some kind"""47
48
49def _ref_format_string() -> str:50return (51"--format=%(refname:lstrip=2)\t%(upstream:lstrip=2)\t%(authordate:rfc)\t%("52"authorname)\t%(subject)"53)54
55
56def _parse_ref_table(text: str):57rows = text.splitlines()58result = []59for row in rows:60columns = row.split("\t")61result.append(62{63"ref_name": columns[0],64"upstream": columns[1],65"date": columns[2],66"author": columns[3],67"subject": columns[4],68}69)70return result71
72
73class GitManager:74"""A class to manage access to git: mostly just provides a simple wrapper around75the basic command-line calls. Provides optional asynchronous access to clone and
76update."""
77
78def __init__(self):79self.git_exe = None80self._find_git()81if not self.git_exe:82raise NoGitFound()83
84def clone(self, remote, local_path, args: List[str] = None):85"""Clones the remote to the local path"""86final_args = ["clone", "--recurse-submodules"]87if args:88final_args.extend(args)89final_args.extend([remote, local_path])90self._synchronous_call_git(final_args)91
92def async_clone(self, remote, local_path, progress_monitor, args: List[str] = None):93"""Clones the remote to the local path, sending periodic progress updates94to the passed progress_monitor. Returns a handle that can be used to
95cancel the job."""
96
97def checkout(self, local_path, spec, args: List[str] = None):98"""Checks out a specific git revision, tag, or branch. Any valid argument to99git checkout can be submitted."""
100old_dir = os.getcwd()101os.chdir(local_path)102final_args = ["checkout"]103if args:104final_args.extend(args)105final_args.append(spec)106self._synchronous_call_git(final_args)107os.chdir(old_dir)108
109def dirty(self, local_path: str) -> bool:110"""Check for local changes"""111old_dir = os.getcwd()112os.chdir(local_path)113result = False114final_args = ["diff-index", "HEAD"]115try:116stdout = self._synchronous_call_git(final_args)117if stdout:118result = True119except GitFailed:120result = False121os.chdir(old_dir)122return result123
124def detached_head(self, local_path: str) -> bool:125"""Check for detached head state"""126old_dir = os.getcwd()127os.chdir(local_path)128result = False129final_args = ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "HEAD"]130try:131stdout = self._synchronous_call_git(final_args)132if stdout == "HEAD":133result = True134except GitFailed:135result = False136os.chdir(old_dir)137return result138
139def update(self, local_path):140"""Fetches and pulls the local_path from its remote"""141old_dir = os.getcwd()142os.chdir(local_path)143try:144self._synchronous_call_git(["fetch"])145self._synchronous_call_git(["pull"])146self._synchronous_call_git(["submodule", "update", "--init", "--recursive"])147except GitFailed as e:148fci.Console.PrintWarning(149translate(150"AddonsInstaller",151"Basic git update failed with the following message:",152)153+ str(e)154+ "\n"155)156fci.Console.PrintWarning(157translate(158"AddonsInstaller",159"Backing up the original directory and re-cloning",160)161+ "...\n"162)163remote = self.get_remote(local_path)164with open(os.path.join(local_path, "ADDON_DISABLED"), "w", encoding="utf-8") as f:165f.write(166"This is a backup of an addon that failed to update cleanly so "167"was re-cloned. It was disabled by the Addon Manager's git update "168"facility and can be safely deleted if the addon is working "169"properly."170)171os.chdir("..")172os.rename(local_path, local_path + ".backup" + str(time.time()))173self.clone(remote, local_path)174os.chdir(old_dir)175
176def status(self, local_path) -> str:177"""Gets the v1 porcelain status"""178old_dir = os.getcwd()179os.chdir(local_path)180try:181status = self._synchronous_call_git(["status", "-sb", "--porcelain"])182except GitFailed as e:183os.chdir(old_dir)184raise e185
186os.chdir(old_dir)187return status188
189def reset(self, local_path, args: List[str] = None):190"""Executes the git reset command"""191old_dir = os.getcwd()192os.chdir(local_path)193final_args = ["reset"]194if args:195final_args.extend(args)196try:197self._synchronous_call_git(final_args)198except GitFailed as e:199os.chdir(old_dir)200raise e201os.chdir(old_dir)202
203def async_fetch_and_update(self, local_path, progress_monitor, args=None):204"""Same as fetch_and_update, but asynchronous"""205
206def update_available(self, local_path) -> bool:207"""Returns True if an update is available from the remote, or false if not"""208old_dir = os.getcwd()209os.chdir(local_path)210try:211self._synchronous_call_git(["fetch"])212status = self._synchronous_call_git(["status", "-sb", "--porcelain"])213except GitFailed as e:214os.chdir(old_dir)215raise e216os.chdir(old_dir)217return "behind" in status218
219def current_tag(self, local_path) -> str:220"""Get the name of the currently checked-out tag if HEAD is detached"""221old_dir = os.getcwd()222os.chdir(local_path)223try:224tag = self._synchronous_call_git(["describe", "--tags"]).strip()225except GitFailed as e:226os.chdir(old_dir)227raise e228os.chdir(old_dir)229return tag230
231def current_branch(self, local_path) -> str:232"""Get the name of the current branch"""233old_dir = os.getcwd()234os.chdir(local_path)235try:236# This only works with git 2.22 and later (June 2019)237# branch = self._synchronous_call_git(["branch", "--show-current"]).strip()238
239# This is more universal (albeit more opaque to the reader):240branch = self._synchronous_call_git(["rev-parse", "--abbrev-ref", "HEAD"]).strip()241except GitFailed as e:242os.chdir(old_dir)243raise e244os.chdir(old_dir)245return branch246
247def repair(self, remote, local_path):248"""Assumes that local_path is supposed to be a local clone of the given249remote, and ensures that it is. Note that any local changes in local_path
250will be destroyed. This is achieved by archiving the old path, cloning an
251entirely new copy, and then deleting the old directory."""
252
253original_cwd = os.getcwd()254
255# Make sure we are not currently in that directory, otherwise on Windows the256# "rename" will fail. To guarantee we aren't in it, change to it, then shift257# up one.258os.chdir(local_path)259os.chdir("..")260backup_path = local_path + ".backup" + str(time.time())261os.rename(local_path, backup_path)262try:263self.clone(remote, local_path)264except GitFailed as e:265fci.Console.PrintError(266translate("AddonsInstaller", "Failed to clone {} into {} using git").format(267remote, local_path268)269)270os.chdir(original_cwd)271raise e272os.chdir(original_cwd)273shutil.rmtree(backup_path, ignore_errors=True)274
275def get_remote(self, local_path) -> str:276"""Get the repository that this local path is set to fetch from"""277old_dir = os.getcwd()278os.chdir(local_path)279try:280response = self._synchronous_call_git(["remote", "-v", "show"])281except GitFailed as e:282os.chdir(old_dir)283raise e284lines = response.split("\n")285result = "(unknown remote)"286for line in lines:287if line.endswith("(fetch)"):288# The line looks like:289# origin https://some/sort/of/path (fetch)290
291segments = line.split()292if len(segments) == 3:293result = segments[1]294break295fci.Console.PrintWarning("Error parsing the results from git remote -v show:\n")296fci.Console.PrintWarning(line + "\n")297os.chdir(old_dir)298return result299
300def get_branches(self, local_path) -> List[str]:301"""Get a list of all available branches (local and remote)"""302old_dir = os.getcwd()303os.chdir(local_path)304try:305stdout = self._synchronous_call_git(["branch", "-a", "--format=%(refname:lstrip=2)"])306except GitFailed as e:307os.chdir(old_dir)308raise e309os.chdir(old_dir)310branches = []311for branch in stdout.split("\n"):312branches.append(branch)313return branches314
315def get_branches_with_info(self, local_path) -> List[Dict[str, str]]:316"""Get a list of branches, where each entry is a dictionary with status information about317the branch."""
318old_dir = os.getcwd()319os.chdir(local_path)320try:321stdout = self._synchronous_call_git(["branch", "-a", _ref_format_string()])322return _parse_ref_table(stdout)323except GitFailed as e:324os.chdir(old_dir)325raise e326
327def get_tags_with_info(self, local_path) -> List[Dict[str, str]]:328"""Get a list of branches, where each entry is a dictionary with status information about329the branch."""
330old_dir = os.getcwd()331os.chdir(local_path)332try:333stdout = self._synchronous_call_git(["tag", "-l", _ref_format_string()])334return _parse_ref_table(stdout)335except GitFailed as e:336os.chdir(old_dir)337raise e338
339def get_last_committers(self, local_path, n=10):340"""Examine the last n entries of the commit history, and return a list of all341the committers, their email addresses, and how many commits each one is
342responsible for.
343"""
344old_dir = os.getcwd()345os.chdir(local_path)346authors = self._synchronous_call_git(["log", f"-{n}", "--format=%cN"]).split("\n")347emails = self._synchronous_call_git(["log", f"-{n}", "--format=%cE"]).split("\n")348os.chdir(old_dir)349
350result_dict = {}351for author, email in zip(authors, emails):352if not author or not email:353continue354if author not in result_dict:355result_dict[author] = {}356result_dict[author]["email"] = [email]357result_dict[author]["count"] = 1358else:359if email not in result_dict[author]["email"]:360# Same author name, new email address -- treat it as the same361# person with a second email, instead of as a whole new person362result_dict[author]["email"].append(email)363result_dict[author]["count"] += 1364return result_dict365
366def get_last_authors(self, local_path, n=10):367"""Examine the last n entries of the commit history, and return a list of all368the authors, their email addresses, and how many commits each one is
369responsible for.
370"""
371old_dir = os.getcwd()372os.chdir(local_path)373authors = self._synchronous_call_git(["log", f"-{n}", "--format=%aN"])374emails = self._synchronous_call_git(["log", f"-{n}", "--format=%aE"])375os.chdir(old_dir)376
377result_dict = {}378for author, email in zip(authors, emails):379if author not in result_dict:380result_dict[author]["email"] = [email]381result_dict[author]["count"] = 1382else:383if email not in result_dict[author]["email"]:384# Same author name, new email address -- treat it as the same385# person with a second email, instead of as a whole new person386result_dict[author]["email"].append(email)387result_dict[author]["count"] += 1388return result_dict389
390def migrate_branch(self, local_path: str, old_branch: str, new_branch: str) -> None:391"""Rename a branch (used when the remote branch name changed). Assumes that "origin"392exists."""
393old_dir = os.getcwd()394os.chdir(local_path)395try:396self._synchronous_call_git(["branch", "-m", old_branch, new_branch])397self._synchronous_call_git(["fetch", "origin"])398self._synchronous_call_git(["branch", "--unset-upstream"])399self._synchronous_call_git(["branch", f"--set-upstream-to=origin/{new_branch}"])400self._synchronous_call_git(["pull"])401except GitFailed as e:402fci.Console.PrintWarning(403translate(404"AddonsInstaller",405"Git branch rename failed with the following message:",406)407+ str(e)408+ "\n"409)410os.chdir(old_dir)411raise e412os.chdir(old_dir)413
414def _find_git(self):415# Find git. In preference order416# A) The value of the GitExecutable user preference417# B) The executable located in the same directory as FreeCAD and called "git"418# C) The result of a shutil search for your system's "git" executable419prefs = fci.ParamGet("User parameter:BaseApp/Preferences/Addons")420git_exe = prefs.GetString("GitExecutable", "Not set")421if not git_exe or git_exe == "Not set" or not os.path.exists(git_exe):422fc_dir = fci.DataPaths().home_dir423git_exe = os.path.join(fc_dir, "bin", "git")424if "Windows" in platform.system():425git_exe += ".exe"426
427if platform.system() == "Darwin" and not self._xcode_command_line_tools_are_installed():428return429
430if not git_exe or not os.path.exists(git_exe):431git_exe = shutil.which("git")432
433if not git_exe or not os.path.exists(git_exe):434return435
436prefs.SetString("GitExecutable", git_exe)437self.git_exe = git_exe438
439def _xcode_command_line_tools_are_installed(self) -> bool:440"""On Macs, there is *always* an executable called "git", but sometimes it's just a441script that tells the user to install XCode's Command Line tools. So the existence of git
442on the Mac actually requires us to check for that installation."""
443try:444subprocess.check_output(["xcode-select", "-p"])445return True446except subprocess.CalledProcessError:447return False448
449def _synchronous_call_git(self, args: List[str]) -> str:450"""Calls git and returns its output."""451final_args = [self.git_exe]452final_args.extend(args)453
454try:455proc = utils.run_interruptable_subprocess(final_args)456except subprocess.CalledProcessError as e:457raise GitFailed(458f"Git returned a non-zero exit status: {e.returncode}\n"459+ f"Called with: {' '.join(final_args)}\n\n"460+ f"Returned stderr:\n{e.stderr}"461) from e462
463return proc.stdout464
465
466def initialize_git() -> Optional[GitManager]:467"""If git is enabled, locate the git executable if necessary and return a new468GitManager object. The executable location is saved in user preferences for reuse,
469and git can be disabled by setting the disableGit parameter in the Addons
470preference group. Returns None if for any of those reasons we aren't using git."""
471
472git_manager = None473pref = fci.ParamGet("User parameter:BaseApp/Preferences/Addons")474disable_git = pref.GetBool("disableGit", False)475if not disable_git:476try:477git_manager = GitManager()478except NoGitFound:479pass480return git_manager481