git_map_branches.py 14 KB

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