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.

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