A console script that allows you to easily update multiple git repositories at once

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