A console script that allows you to easily update multiple git repositories at once
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

297 рядки
11 KiB

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