git_map_branches.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. #!/usr/bin/env python
  2. # Copyright 2014 The Chromium Authors. All rights reserved.
  3. # Use of this source code is governed by a BSD-style license that can be
  4. # found in the LICENSE file.
  5. """Print dependency tree of branches in local repo.
  6. Example:
  7. origin/main
  8. cool_feature
  9. dependent_feature
  10. other_dependent_feature
  11. other_feature
  12. Branches are colorized as follows:
  13. * Red - a remote branch (usually the root of all local branches)
  14. * Cyan - a local branch which is the same as HEAD
  15. * Note that multiple branches may be Cyan, if they are all on the same
  16. commit, and you have that commit checked out.
  17. * Green - a local branch
  18. * Blue - a 'branch-heads' branch
  19. * Magenta - a tag
  20. * Magenta '{NO UPSTREAM}' - If you have local branches which do not track any
  21. upstream, then you will see this.
  22. """
  23. from __future__ import print_function
  24. import argparse
  25. import collections
  26. import os
  27. import subprocess2
  28. import sys
  29. from git_common import current_branch, upstream, tags, get_branches_info
  30. from git_common import get_git_version, MIN_UPSTREAM_TRACK_GIT_VERSION, hash_one
  31. from git_common import run
  32. import setup_color
  33. from third_party.colorama import Fore, Style
  34. DEFAULT_SEPARATOR = ' ' * 4
  35. class OutputManager(object):
  36. """Manages a number of OutputLines and formats them into aligned columns."""
  37. def __init__(self):
  38. self.lines = []
  39. self.nocolor = False
  40. self.max_column_lengths = []
  41. self.num_columns = None
  42. def append(self, line):
  43. # All lines must have the same number of columns.
  44. if not self.num_columns:
  45. self.num_columns = len(line.columns)
  46. self.max_column_lengths = [0] * self.num_columns
  47. assert self.num_columns == len(line.columns)
  48. if self.nocolor:
  49. line.colors = [''] * self.num_columns
  50. self.lines.append(line)
  51. # Update maximum column lengths.
  52. for i, col in enumerate(line.columns):
  53. self.max_column_lengths[i] = max(self.max_column_lengths[i], len(col))
  54. def as_formatted_string(self):
  55. return '\n'.join(
  56. l.as_padded_string(self.max_column_lengths) for l in self.lines)
  57. class OutputLine(object):
  58. """A single line of data.
  59. This consists of an equal number of columns, colors and separators."""
  60. def __init__(self):
  61. self.columns = []
  62. self.separators = []
  63. self.colors = []
  64. def append(self, data, separator=DEFAULT_SEPARATOR, color=Fore.WHITE):
  65. self.columns.append(data)
  66. self.separators.append(separator)
  67. self.colors.append(color)
  68. def as_padded_string(self, max_column_lengths):
  69. """"Returns the data as a string with each column padded to
  70. |max_column_lengths|."""
  71. output_string = ''
  72. for i, (color, data, separator) in enumerate(
  73. zip(self.colors, self.columns, self.separators)):
  74. if max_column_lengths[i] == 0:
  75. continue
  76. padding = (max_column_lengths[i] - len(data)) * ' '
  77. output_string += color + data + padding + separator
  78. return output_string.rstrip()
  79. class BranchMapper(object):
  80. """A class which constructs output representing the tree's branch structure.
  81. Attributes:
  82. __branches_info: a map of branches to their BranchesInfo objects which
  83. consist of the branch hash, upstream and ahead/behind status.
  84. __gone_branches: a set of upstreams which are not fetchable by git"""
  85. def __init__(self):
  86. self.verbosity = 0
  87. self.maxjobs = 0
  88. self.show_subject = False
  89. self.output = OutputManager()
  90. self.__gone_branches = set()
  91. self.__branches_info = None
  92. self.__parent_map = collections.defaultdict(list)
  93. self.__current_branch = None
  94. self.__current_hash = None
  95. self.__tag_set = None
  96. self.__status_info = {}
  97. def start(self):
  98. self.__branches_info = get_branches_info(
  99. include_tracking_status=self.verbosity >= 1)
  100. if (self.verbosity >= 2):
  101. # Avoid heavy import unless necessary.
  102. from git_cl import get_cl_statuses, color_for_status, Changelist
  103. change_cls = [Changelist(branchref='refs/heads/'+b)
  104. for b in self.__branches_info.keys() if b]
  105. status_info = get_cl_statuses(change_cls,
  106. fine_grained=self.verbosity > 2,
  107. max_processes=self.maxjobs)
  108. # This is a blocking get which waits for the remote CL status to be
  109. # retrieved.
  110. for cl, status in status_info:
  111. self.__status_info[cl.GetBranch()] = (cl.GetIssueURL(short=True),
  112. color_for_status(status), status)
  113. roots = set()
  114. # A map of parents to a list of their children.
  115. for branch, branch_info in self.__branches_info.items():
  116. if not branch_info:
  117. continue
  118. parent = branch_info.upstream
  119. if self.__check_cycle(branch):
  120. continue
  121. if not self.__branches_info[parent]:
  122. branch_upstream = upstream(branch)
  123. # If git can't find the upstream, mark the upstream as gone.
  124. if branch_upstream:
  125. parent = branch_upstream
  126. else:
  127. self.__gone_branches.add(parent)
  128. # A parent that isn't in the branches info is a root.
  129. roots.add(parent)
  130. self.__parent_map[parent].append(branch)
  131. self.__current_branch = current_branch()
  132. self.__current_hash = hash_one('HEAD', short=True)
  133. self.__tag_set = tags()
  134. if roots:
  135. for root in sorted(roots):
  136. self.__append_branch(root)
  137. else:
  138. no_branches = OutputLine()
  139. no_branches.append('No User Branches')
  140. self.output.append(no_branches)
  141. def __check_cycle(self, branch):
  142. # Maximum length of the cycle is `num_branches`. This limit avoids running
  143. # into a cycle which does *not* contain `branch`.
  144. num_branches = len(self.__branches_info)
  145. cycle = [branch]
  146. while len(cycle) < num_branches and self.__branches_info[cycle[-1]]:
  147. parent = self.__branches_info[cycle[-1]].upstream
  148. cycle.append(parent)
  149. if parent == branch:
  150. print('Warning: Detected cycle in branches: {}'.format(
  151. ' -> '.join(cycle)), file=sys.stderr)
  152. return True
  153. return False
  154. def __is_invalid_parent(self, parent):
  155. return not parent or parent in self.__gone_branches
  156. def __color_for_branch(self, branch, branch_hash):
  157. if branch.startswith('origin/'):
  158. color = Fore.RED
  159. elif branch.startswith('branch-heads'):
  160. color = Fore.BLUE
  161. elif self.__is_invalid_parent(branch) or branch in self.__tag_set:
  162. color = Fore.MAGENTA
  163. elif self.__current_hash.startswith(branch_hash):
  164. color = Fore.CYAN
  165. else:
  166. color = Fore.GREEN
  167. if branch_hash and self.__current_hash.startswith(branch_hash):
  168. color += Style.BRIGHT
  169. else:
  170. color += Style.NORMAL
  171. return color
  172. def __append_branch(self, branch, depth=0):
  173. """Recurses through the tree structure and appends an OutputLine to the
  174. OutputManager for each branch."""
  175. branch_info = self.__branches_info[branch]
  176. if branch_info:
  177. branch_hash = branch_info.hash
  178. else:
  179. try:
  180. branch_hash = hash_one(branch, short=True)
  181. except subprocess2.CalledProcessError:
  182. branch_hash = None
  183. line = OutputLine()
  184. # The branch name with appropriate indentation.
  185. suffix = ''
  186. if branch == self.__current_branch or (
  187. self.__current_branch == 'HEAD' and branch == self.__current_hash):
  188. suffix = ' *'
  189. branch_string = branch
  190. if branch in self.__gone_branches:
  191. branch_string = '{%s:GONE}' % branch
  192. if not branch:
  193. branch_string = '{NO_UPSTREAM}'
  194. main_string = ' ' * depth + branch_string + suffix
  195. line.append(
  196. main_string,
  197. color=self.__color_for_branch(branch, branch_hash))
  198. # The branch hash.
  199. if self.verbosity >= 2:
  200. line.append(branch_hash or '', separator=' ', color=Fore.RED)
  201. # The branch tracking status.
  202. if self.verbosity >= 1:
  203. commits_string = ''
  204. behind_string = ''
  205. front_separator = ''
  206. center_separator = ''
  207. back_separator = ''
  208. if branch_info and not self.__is_invalid_parent(branch_info.upstream):
  209. behind = branch_info.behind
  210. commits = branch_info.commits
  211. if commits:
  212. commits_string = '%d commit' % commits
  213. commits_string += 's' if commits > 1 else ' '
  214. if behind:
  215. behind_string = 'behind %d' % behind
  216. if commits or behind:
  217. front_separator = '['
  218. back_separator = ']'
  219. if commits and behind:
  220. center_separator = '|'
  221. line.append(front_separator, separator=' ')
  222. line.append(commits_string, separator=' ', color=Fore.MAGENTA)
  223. line.append(center_separator, separator=' ')
  224. line.append(behind_string, separator=' ', color=Fore.MAGENTA)
  225. line.append(back_separator)
  226. # The Rietveld issue associated with the branch.
  227. if self.verbosity >= 2:
  228. (url, color, status) = ('', '', '') if self.__is_invalid_parent(branch) \
  229. else self.__status_info[branch]
  230. if self.verbosity > 2:
  231. line.append('{} ({})'.format(url, status) if url else '', color=color)
  232. else:
  233. line.append(url or '', color=color)
  234. # The subject of the most recent commit on the branch.
  235. if self.show_subject:
  236. if branch:
  237. line.append(run('log', '-n1', '--format=%s', branch, '--'))
  238. else:
  239. line.append('')
  240. self.output.append(line)
  241. for child in sorted(self.__parent_map.pop(branch, ())):
  242. self.__append_branch(child, depth=depth + 1)
  243. def print_desc():
  244. for line in __doc__.splitlines():
  245. starpos = line.find('* ')
  246. if starpos == -1 or '-' not in line:
  247. print(line)
  248. else:
  249. _, color, rest = line.split(None, 2)
  250. outline = line[:starpos+1]
  251. outline += getattr(Fore, color.upper()) + " " + color + " " + Fore.RESET
  252. outline += rest
  253. print(outline)
  254. print('')
  255. def main(argv):
  256. setup_color.init()
  257. if get_git_version() < MIN_UPSTREAM_TRACK_GIT_VERSION:
  258. print(
  259. 'This tool will not show all tracking information for git version '
  260. 'earlier than ' +
  261. '.'.join(str(x) for x in MIN_UPSTREAM_TRACK_GIT_VERSION) +
  262. '. Please consider upgrading.', file=sys.stderr)
  263. if '-h' in argv:
  264. print_desc()
  265. parser = argparse.ArgumentParser()
  266. parser.add_argument('-v', action='count', default=0,
  267. help=('Pass once to show tracking info, '
  268. 'twice for hash and review url, '
  269. 'thrice for review status'))
  270. parser.add_argument('--no-color', action='store_true', dest='nocolor',
  271. help='Turn off colors.')
  272. parser.add_argument(
  273. '-j', '--maxjobs', action='store', type=int,
  274. help='The number of jobs to use when retrieving review status')
  275. parser.add_argument('--show-subject', action='store_true',
  276. dest='show_subject', help='Show the commit subject.')
  277. opts = parser.parse_args(argv)
  278. mapper = BranchMapper()
  279. mapper.verbosity = opts.v
  280. mapper.output.nocolor = opts.nocolor
  281. mapper.maxjobs = opts.maxjobs
  282. mapper.show_subject = opts.show_subject
  283. mapper.start()
  284. print(mapper.output.as_formatted_string())
  285. return 0
  286. if __name__ == '__main__':
  287. try:
  288. sys.exit(main(sys.argv[1:]))
  289. except KeyboardInterrupt:
  290. sys.stderr.write('interrupted\n')
  291. sys.exit(1)