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.

308 lines
11 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2011-2015 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. class _Stasher(object):
  46. """Manages the stash state of a given repository."""
  47. def __init__(self, repo):
  48. self._repo = repo
  49. self._clean = self._stashed = False
  50. def clean(self):
  51. """Ensure the working directory is clean, so we can do checkouts."""
  52. if not self._clean:
  53. res = self._repo.git.stash("--all")
  54. self._clean = True
  55. if res != "No local changes to save":
  56. self._stashed = True
  57. def restore(self):
  58. """Restore the pre-stash state."""
  59. if self._stashed:
  60. self._repo.git.stash("pop", "--index")
  61. def _read_config(repo, attr):
  62. """Read an attribute from git config."""
  63. try:
  64. return repo.git.config("--get", attr)
  65. except exc.GitCommandError:
  66. return None
  67. def _fetch_remotes(remotes):
  68. """Fetch a list of remotes, displaying progress info along the way."""
  69. def _get_name(ref):
  70. """Return the local name of a remote or tag reference."""
  71. return ref.remote_head if isinstance(ref, RemoteRef) else ref.name
  72. info = [("NEW_HEAD", "new branch", "new branches"),
  73. ("NEW_TAG", "new tag", "new tags"),
  74. ("FAST_FORWARD", "branch update", "branch updates")]
  75. up_to_date = BLUE + "up to date" + RESET
  76. for remote in remotes:
  77. print(INDENT2, "Fetching", BOLD + remote.name, end="")
  78. if not remote.config_reader.has_option("fetch"):
  79. print(":", YELLOW + "skipped:", "no configured refspec.")
  80. continue
  81. try:
  82. results = remote.fetch(progress=_ProgressMonitor())
  83. except exc.GitCommandError as err:
  84. msg = err.command[0].replace("Error when fetching: ", "")
  85. if not msg.endswith("."):
  86. msg += "."
  87. print(":", RED + "error:", msg)
  88. return
  89. except AssertionError: # Seems to be the result of a bug in GitPython
  90. # This happens when git initiates an auto-gc during fetch:
  91. print(":", RED + "error:", "something went wrong in GitPython,",
  92. "but the fetch might have been successful.")
  93. return
  94. rlist = []
  95. for attr, singular, plural in info:
  96. names = [_get_name(res.ref)
  97. for res in results if res.flags & getattr(res, attr)]
  98. if names:
  99. desc = singular if len(names) == 1 else plural
  100. colored = GREEN + desc + RESET
  101. rlist.append("{0} ({1})".format(colored, ", ".join(names)))
  102. print(":", (", ".join(rlist) if rlist else up_to_date) + ".")
  103. def _is_up_to_date(repo, branch, upstream):
  104. """Return whether *branch* is up-to-date with its *upstream*."""
  105. base = repo.git.merge_base(branch.commit, upstream.commit)
  106. return repo.commit(base) == upstream.commit
  107. def _rebase(repo, name):
  108. """Rebase the current HEAD of *repo* onto the branch *name*."""
  109. print(GREEN + "rebasing...", end="")
  110. try:
  111. res = repo.git.rebase(name, "--preserve-merges")
  112. except exc.GitCommandError as err:
  113. msg = err.stderr.replace("\n", " ").strip()
  114. if not msg.endswith("."):
  115. msg += "."
  116. if "unstaged changes" in msg:
  117. print(RED + " error:", "unstaged changes.")
  118. elif "uncommitted changes" in msg:
  119. print(RED + " error:", "uncommitted changes.")
  120. else:
  121. try:
  122. repo.git.rebase("--abort")
  123. except exc.GitCommandError:
  124. pass
  125. print(RED + " error:", msg if msg else "rebase conflict.",
  126. "Aborted.")
  127. else:
  128. print("\b" * 6 + " " * 6 + "\b" * 6 + GREEN + "ed", end=".\n")
  129. def _merge(repo, name):
  130. """Merge the branch *name* into the current HEAD of *repo*."""
  131. print(GREEN + "merging...", end="")
  132. try:
  133. repo.git.merge(name)
  134. except exc.GitCommandError as err:
  135. msg = err.stderr.replace("\n", " ").strip()
  136. if not msg.endswith("."):
  137. msg += "."
  138. if "local changes" in msg and "would be overwritten" in msg:
  139. print(RED + " error:", "uncommitted changes.")
  140. else:
  141. try:
  142. repo.git.merge("--abort")
  143. except exc.GitCommandError:
  144. pass
  145. print(RED + " error:", msg if msg else "merge conflict.",
  146. "Aborted.")
  147. else:
  148. print("\b" * 6 + " " * 6 + "\b" * 6 + GREEN + "ed", end=".\n")
  149. def _update_branch(repo, branch, merge, rebase, stasher=None):
  150. """Update a single branch."""
  151. print(INDENT2, "Updating", BOLD + branch.name, end=": ")
  152. upstream = branch.tracking_branch()
  153. if not upstream:
  154. print(YELLOW + "skipped:", "no upstream is tracked.")
  155. return
  156. try:
  157. branch.commit, upstream.commit
  158. except ValueError:
  159. print(YELLOW + "skipped:", "branch has no revisions.")
  160. return
  161. if _is_up_to_date(repo, branch, upstream):
  162. print(BLUE + "up to date", end=".\n")
  163. return
  164. if stasher:
  165. stasher.clean()
  166. branch.checkout()
  167. config_attr = "branch.{0}.rebase".format(branch.name)
  168. if not merge and (rebase or _read_config(repo, config_attr)):
  169. _rebase(repo, upstream.name)
  170. else:
  171. _merge(repo, upstream.name)
  172. def _update_branches(repo, active, merge, rebase):
  173. """Update a list of branches."""
  174. if active:
  175. _update_branch(repo, active, merge, rebase)
  176. branches = set(repo.heads) - {active}
  177. if branches:
  178. stasher = _Stasher(repo)
  179. try:
  180. for branch in sorted(branches, key=lambda b: b.name):
  181. _update_branch(repo, branch, merge, rebase, stasher)
  182. finally:
  183. if active:
  184. active.checkout()
  185. stasher.restore()
  186. def _update_repository(repo, current_only=False, rebase=False, merge=False):
  187. """Update a single git repository by fetching remotes and rebasing/merging.
  188. The specific actions depend on the arguments given. We will fetch all
  189. remotes if *current_only* is ``False``, or only the remote tracked by the
  190. current branch if ``True``. By default, we will merge unless
  191. ``pull.rebase`` or ``branch.<name>.rebase`` is set in config; *rebase* will
  192. cause us to always rebase with ``--preserve-merges``, and *merge* will
  193. cause us to always merge.
  194. """
  195. print(INDENT1, BOLD + os.path.split(repo.working_dir)[1] + ":")
  196. try:
  197. active = repo.active_branch
  198. except TypeError: # Happens when HEAD is detached
  199. active = None
  200. if current_only:
  201. if not active:
  202. print(INDENT2, ERROR,
  203. "--current-only doesn't make sense with a detached HEAD.")
  204. return
  205. ref = active.tracking_branch()
  206. if not ref:
  207. print(INDENT2, ERROR, "no remote tracked by current branch.")
  208. return
  209. remotes = [repo.remotes[ref.remote_name]]
  210. else:
  211. remotes = repo.remotes
  212. if not remotes:
  213. print(INDENT2, ERROR, "no remotes configured to fetch.")
  214. return
  215. _fetch_remotes(remotes)
  216. if not repo.bare:
  217. rebase = rebase or _read_config(repo, "pull.rebase")
  218. _update_branches(repo, active, merge, rebase)
  219. def _update_subdirectories(path, long_name, update_args):
  220. """Update all subdirectories that are git repos in a given directory."""
  221. repos = []
  222. for item in os.listdir(path):
  223. try:
  224. repo = Repo(os.path.join(path, item))
  225. except (exc.InvalidGitRepositoryError, exc.NoSuchPathError):
  226. continue
  227. repos.append(repo)
  228. suffix = "ies" if len(repos) != 1 else "y"
  229. print(long_name[0].upper() + long_name[1:],
  230. "contains {0} git repositor{1}:".format(len(repos), suffix))
  231. for repo in sorted(repos, key=lambda r: os.path.split(r.working_dir)[1]):
  232. _update_repository(repo, *update_args)
  233. def _update_directory(path, update_args, is_bookmark=False):
  234. """Update a particular directory.
  235. Determine whether the directory is a git repo on its own, a directory of
  236. git repositories, or something invalid. If the first, update the single
  237. repository; if the second, update all repositories contained within; if the
  238. third, print an error.
  239. """
  240. dir_type = "bookmark" if is_bookmark else "directory"
  241. long_name = dir_type + ' "' + BOLD + path + RESET + '"'
  242. try:
  243. repo = Repo(path)
  244. except exc.NoSuchPathError:
  245. print(ERROR, long_name, "doesn't exist!")
  246. except exc.InvalidGitRepositoryError:
  247. if os.path.isdir(path):
  248. _update_subdirectories(path, long_name, update_args)
  249. else:
  250. print(ERROR, long_name, "isn't a repository!")
  251. else:
  252. long_name = (dir_type.capitalize() + ' "' + BOLD + repo.working_dir +
  253. RESET + '"')
  254. print(long_name, "is a git repository:")
  255. _update_repository(repo, *update_args)
  256. def update_bookmarks(bookmarks, update_args):
  257. """Loop through and update all bookmarks."""
  258. if bookmarks:
  259. for path, name in bookmarks:
  260. _update_directory(path, update_args, is_bookmark=True)
  261. else:
  262. print("You don't have any bookmarks configured! Get help with 'gitup -h'.")
  263. def update_directories(paths, update_args):
  264. """Update a list of directories supplied by command arguments."""
  265. for path in paths:
  266. full_path = os.path.abspath(path)
  267. _update_directory(full_path, update_args, is_bookmark=False)