git_map_branches.py 11 KB

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