git_map_branches.py 12 KB

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