A console script that allows you to easily update multiple git repositories at once
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

228 lines
8.3 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2011-2016 Ben Kurtovic <ben.kurtovic@gmail.com>
  4. # Released under the terms of the MIT License. See LICENSE for details.
  5. from __future__ import print_function
  6. import os
  7. from colorama import Fore, Style
  8. from git import RemoteReference as RemoteRef, Repo, exc
  9. from git.util import RemoteProgress
  10. __all__ = ["update_bookmarks", "update_directories"]
  11. BOLD = Style.BRIGHT
  12. BLUE = Fore.BLUE + BOLD
  13. GREEN = Fore.GREEN + BOLD
  14. RED = Fore.RED + BOLD
  15. YELLOW = Fore.YELLOW + BOLD
  16. RESET = Style.RESET_ALL
  17. INDENT1 = " " * 3
  18. INDENT2 = " " * 7
  19. ERROR = RED + "Error:" + RESET
  20. class _ProgressMonitor(RemoteProgress):
  21. """Displays relevant output during the fetching process."""
  22. def __init__(self):
  23. super(_ProgressMonitor, self).__init__()
  24. self._started = False
  25. def update(self, op_code, cur_count, max_count=None, message=''):
  26. """Called whenever progress changes. Overrides default behavior."""
  27. if op_code & (self.COMPRESSING | self.RECEIVING):
  28. cur_count = str(int(cur_count))
  29. if max_count:
  30. max_count = str(int(max_count))
  31. if op_code & self.BEGIN:
  32. print("\b, " if self._started else " (", end="")
  33. if not self._started:
  34. self._started = True
  35. if op_code & self.END:
  36. end = ")"
  37. elif max_count:
  38. end = "\b" * (1 + len(cur_count) + len(max_count))
  39. else:
  40. end = "\b" * len(cur_count)
  41. if max_count:
  42. print("{0}/{1}".format(cur_count, max_count), end=end)
  43. else:
  44. print(str(cur_count), end=end)
  45. def _fetch_remotes(remotes, prune):
  46. """Fetch a list of remotes, displaying progress info along the way."""
  47. def _get_name(ref):
  48. """Return the local name of a remote or tag reference."""
  49. return ref.remote_head if isinstance(ref, RemoteRef) else ref.name
  50. # TODO: missing branch deleted (via --prune):
  51. info = [("NEW_HEAD", "new branch", "new branches"),
  52. ("NEW_TAG", "new tag", "new tags"),
  53. ("FAST_FORWARD", "branch update", "branch updates")]
  54. up_to_date = BLUE + "up to date" + RESET
  55. for remote in remotes:
  56. print(INDENT2, "Fetching", BOLD + remote.name, end="")
  57. if not remote.config_reader.has_option("fetch"):
  58. print(":", YELLOW + "skipped:", "no configured refspec.")
  59. continue
  60. try:
  61. results = remote.fetch(progress=_ProgressMonitor(), prune=prune)
  62. except exc.GitCommandError as err:
  63. msg = err.command[0].replace("Error when fetching: ", "")
  64. if not msg.endswith("."):
  65. msg += "."
  66. print(":", RED + "error:", msg)
  67. return
  68. except AssertionError: # Seems to be the result of a bug in GitPython
  69. # This happens when git initiates an auto-gc during fetch:
  70. print(":", RED + "error:", "something went wrong in GitPython,",
  71. "but the fetch might have been successful.")
  72. return
  73. rlist = []
  74. for attr, singular, plural in info:
  75. names = [_get_name(res.ref)
  76. for res in results if res.flags & getattr(res, attr)]
  77. if names:
  78. desc = singular if len(names) == 1 else plural
  79. colored = GREEN + desc + RESET
  80. rlist.append("{0} ({1})".format(colored, ", ".join(names)))
  81. print(":", (", ".join(rlist) if rlist else up_to_date) + ".")
  82. def _update_branch(repo, branch, is_active=False):
  83. """Update a single branch."""
  84. print(INDENT2, "Updating", BOLD + branch.name, end=": ")
  85. upstream = branch.tracking_branch()
  86. if not upstream:
  87. print(YELLOW + "skipped:", "no upstream is tracked.")
  88. return
  89. try:
  90. branch.commit
  91. except ValueError:
  92. print(YELLOW + "skipped:", "branch has no revisions.")
  93. return
  94. try:
  95. upstream.commit
  96. except ValueError:
  97. print(YELLOW + "skipped:", "upstream does not exist.")
  98. return
  99. base = repo.git.merge_base(branch.commit, upstream.commit)
  100. if repo.commit(base) == upstream.commit:
  101. print(BLUE + "up to date", end=".\n")
  102. return
  103. if is_active:
  104. try:
  105. repo.git.merge(upstream.name, ff_only=True)
  106. print(GREEN + "done", end=".\n")
  107. except exc.GitCommandError as err:
  108. msg = err.stderr
  109. if "local changes" in msg and "would be overwritten" in msg:
  110. print(YELLOW + "skipped:", "uncommitted changes.")
  111. else:
  112. print(YELLOW + "skipped:", "not possible to fast-forward.")
  113. else:
  114. status = repo.git.merge_base(
  115. branch.commit, upstream.commit, is_ancestor=True,
  116. with_extended_output=True, with_exceptions=False)[0]
  117. if status != 0:
  118. print(YELLOW + "skipped:", "not possible to fast-forward.")
  119. else:
  120. repo.git.branch(branch.name, upstream.name, force=True)
  121. print(GREEN + "done", end=".\n")
  122. def _update_repository(repo, current_only, fetch_only, prune):
  123. """Update a single git repository by fetching remotes and rebasing/merging.
  124. The specific actions depend on the arguments given. We will fetch all
  125. remotes if *current_only* is ``False``, or only the remote tracked by the
  126. current branch if ``True``. If *fetch_only* is ``False``, we will also
  127. update all fast-forwardable branches that are tracking valid upstreams.
  128. If *prune* is ``True``, remote-tracking branches that no longer exist on
  129. their remote after fetching will be deleted.
  130. """
  131. print(INDENT1, BOLD + os.path.split(repo.working_dir)[1] + ":")
  132. try:
  133. active = repo.active_branch
  134. except TypeError: # Happens when HEAD is detached
  135. active = None
  136. if current_only:
  137. if not active:
  138. print(INDENT2, ERROR,
  139. "--current-only doesn't make sense with a detached HEAD.")
  140. return
  141. ref = active.tracking_branch()
  142. if not ref:
  143. print(INDENT2, ERROR, "no remote tracked by current branch.")
  144. return
  145. remotes = [repo.remotes[ref.remote_name]]
  146. else:
  147. remotes = repo.remotes
  148. if not remotes:
  149. print(INDENT2, ERROR, "no remotes configured to fetch.")
  150. return
  151. _fetch_remotes(remotes, prune)
  152. if not fetch_only:
  153. for branch in sorted(repo.heads, key=lambda b: b.name):
  154. _update_branch(repo, branch, branch == active)
  155. def _update_subdirectories(path, update_args):
  156. """Update all subdirectories that are git repos in a given directory."""
  157. repos = []
  158. for item in os.listdir(path):
  159. try:
  160. repo = Repo(os.path.join(path, item))
  161. except (exc.InvalidGitRepositoryError, exc.NoSuchPathError):
  162. continue
  163. repos.append(repo)
  164. suffix = "" if len(repos) == 1 else "s"
  165. print(BOLD + path, "({0} repo{1}):".format(len(repos), suffix))
  166. for repo in sorted(repos, key=lambda r: os.path.split(r.working_dir)[1]):
  167. _update_repository(repo, *update_args)
  168. def _update_directory(path, update_args):
  169. """Update a particular directory.
  170. Determine whether the directory is a git repo on its own, a directory of
  171. git repositories, or something invalid. If the first, update the single
  172. repository; if the second, update all repositories contained within; if the
  173. third, print an error.
  174. """
  175. try:
  176. repo = Repo(path)
  177. except exc.NoSuchPathError:
  178. print(ERROR, BOLD + path, "doesn't exist!")
  179. except exc.InvalidGitRepositoryError:
  180. if os.path.isdir(path):
  181. _update_subdirectories(path, update_args)
  182. else:
  183. print(ERROR, BOLD + path, "isn't a repository!")
  184. else:
  185. print(BOLD + repo.working_dir, "(1 repo):")
  186. _update_repository(repo, *update_args)
  187. def update_bookmarks(bookmarks, update_args):
  188. """Loop through and update all bookmarks."""
  189. if bookmarks:
  190. for path in bookmarks:
  191. _update_directory(path, update_args)
  192. else:
  193. print("You don't have any bookmarks configured! Get help with 'gitup -h'.")
  194. def update_directories(paths, update_args):
  195. """Update a list of directories supplied by command arguments."""
  196. for path in paths:
  197. full_path = os.path.abspath(path)
  198. _update_directory(full_path, update_args)