git_map_branches.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  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 get_config, 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. if self.__check_cycle(branch):
  126. continue
  127. parent = branch_info.upstream
  128. if not self.__branches_info[parent]:
  129. # If the parent is not a known branch, it may be an upstream
  130. # branch like origin/main or it may be gone. Determine which it
  131. # is, but don't re-query the same parent multiple times.
  132. if parent not in roots:
  133. if not upstream(branch):
  134. self.__gone_branches.add(parent)
  135. roots.add(parent)
  136. self.__parent_map[parent].append(branch)
  137. self.__current_branch = current_branch()
  138. self.__current_hash = hash_one('HEAD', short=True)
  139. self.__tag_set = tags()
  140. if roots:
  141. for root in sorted(roots):
  142. self.__append_branch(root, self.output)
  143. else:
  144. no_branches = OutputLine()
  145. no_branches.append('No User Branches')
  146. self.output.append(no_branches)
  147. def __check_cycle(self, branch):
  148. # Maximum length of the cycle is `num_branches`. This limit avoids
  149. # running into a cycle which does *not* contain `branch`.
  150. num_branches = len(self.__branches_info)
  151. cycle = [branch]
  152. while len(cycle) < num_branches and self.__branches_info[cycle[-1]]:
  153. parent = self.__branches_info[cycle[-1]].upstream
  154. cycle.append(parent)
  155. if parent == branch:
  156. print('Warning: Detected cycle in branches: {}'.format(
  157. ' -> '.join(cycle)),
  158. file=sys.stderr)
  159. return True
  160. return False
  161. def __is_invalid_parent(self, parent):
  162. return not parent or parent in self.__gone_branches
  163. def __color_for_branch(self, branch, branch_hash):
  164. if branch.startswith('origin/'):
  165. color = Fore.RED
  166. elif branch.startswith('branch-heads'):
  167. color = Fore.BLUE
  168. elif self.__is_invalid_parent(branch) or branch in self.__tag_set:
  169. color = Fore.MAGENTA
  170. elif self.__current_hash.startswith(branch_hash):
  171. color = Fore.CYAN
  172. else:
  173. color = Fore.GREEN
  174. if branch_hash and self.__current_hash.startswith(branch_hash):
  175. color += Style.BRIGHT
  176. else:
  177. color += Style.NORMAL
  178. return color
  179. def __is_dormant_branch(self, branch):
  180. if '/' in branch:
  181. return False
  182. return get_config(f'branch.{branch}.dormant') == 'true'
  183. def __append_branch(self, branch, output, depth=0):
  184. """Recurses through the tree structure and appends an OutputLine to the
  185. OutputManager for each branch."""
  186. child_output = OutputManager()
  187. for child in sorted(self.__parent_map.pop(branch, ())):
  188. self.__append_branch(child, child_output, depth=depth + 1)
  189. is_dormant_branch = self.__is_dormant_branch(branch)
  190. if self.hide_dormant and is_dormant_branch and not child_output.lines:
  191. return
  192. branch_info = self.__branches_info[branch]
  193. if branch_info:
  194. branch_hash = branch_info.hash
  195. else:
  196. try:
  197. branch_hash = hash_one(branch, short=True)
  198. except subprocess2.CalledProcessError:
  199. branch_hash = None
  200. line = OutputLine()
  201. # The branch name with appropriate indentation.
  202. suffix = ''
  203. if branch == self.__current_branch or (self.__current_branch == 'HEAD'
  204. and branch
  205. == self.__current_hash):
  206. suffix = ' *'
  207. branch_string = branch
  208. if branch in self.__gone_branches:
  209. branch_string = '{%s:GONE}' % branch
  210. if not branch:
  211. branch_string = '{NO_UPSTREAM}'
  212. main_string = ' ' * depth + branch_string + suffix
  213. line.append(main_string,
  214. color=self.__color_for_branch(branch, branch_hash))
  215. # The branch hash.
  216. if self.verbosity >= 2:
  217. line.append(branch_hash or '', separator=' ', color=Fore.RED)
  218. # The branch tracking status.
  219. if self.verbosity >= 1:
  220. commits_string = ''
  221. behind_string = ''
  222. front_separator = ''
  223. center_separator = ''
  224. back_separator = ''
  225. if branch_info and not self.__is_invalid_parent(
  226. branch_info.upstream):
  227. behind = branch_info.behind
  228. commits = branch_info.commits
  229. if commits:
  230. commits_string = '%d commit' % commits
  231. commits_string += 's' if commits > 1 else ' '
  232. if behind:
  233. behind_string = 'behind %d' % behind
  234. if commits or behind:
  235. front_separator = '['
  236. back_separator = ']'
  237. if commits and behind:
  238. center_separator = '|'
  239. line.append(front_separator, separator=' ')
  240. line.append(commits_string, separator=' ', color=Fore.MAGENTA)
  241. line.append(center_separator, separator=' ')
  242. line.append(behind_string, separator=' ', color=Fore.MAGENTA)
  243. line.append(back_separator)
  244. if self.verbosity >= 4:
  245. line.append(' (dormant)' if is_dormant_branch else ' ',
  246. separator=' ',
  247. color=Fore.RED)
  248. # The Rietveld issue associated with the branch.
  249. if self.verbosity >= 2:
  250. (url, color,
  251. status) = (('', '', '') if self.__is_invalid_parent(branch) else
  252. self.__status_info[branch])
  253. if self.verbosity > 2:
  254. line.append('{} ({})'.format(url, status) if url else '',
  255. color=color)
  256. else:
  257. line.append(url or '', color=color)
  258. # The subject of the most recent commit on the branch.
  259. if self.show_subject:
  260. if not self.__is_invalid_parent(branch):
  261. line.append(run('log', '-n1', '--format=%s', branch, '--'))
  262. else:
  263. line.append('')
  264. output.append(line)
  265. output.merge(child_output)
  266. def print_desc():
  267. for line in __doc__.splitlines():
  268. starpos = line.find('* ')
  269. if starpos == -1 or '-' not in line:
  270. print(line)
  271. else:
  272. _, color, rest = line.split(None, 2)
  273. outline = line[:starpos + 1]
  274. outline += getattr(Fore,
  275. color.upper()) + " " + color + " " + Fore.RESET
  276. outline += rest
  277. print(outline)
  278. print('')
  279. @metrics.collector.collect_metrics('git map-branches')
  280. def main(argv):
  281. setup_color.init()
  282. if get_git_version() < MIN_UPSTREAM_TRACK_GIT_VERSION:
  283. print(
  284. 'This tool will not show all tracking information for git version '
  285. 'earlier than ' +
  286. '.'.join(str(x) for x in MIN_UPSTREAM_TRACK_GIT_VERSION) +
  287. '. Please consider upgrading.',
  288. file=sys.stderr)
  289. if '-h' in argv:
  290. print_desc()
  291. parser = argparse.ArgumentParser()
  292. parser.add_argument('-v',
  293. action='count',
  294. default=0,
  295. help=('Pass once to show tracking info, '
  296. 'twice for hash and review url, '
  297. 'thrice for review status, '
  298. 'four times to mark dormant branches'))
  299. parser.add_argument('--no-color',
  300. action='store_true',
  301. dest='nocolor',
  302. help='Turn off colors.')
  303. parser.add_argument(
  304. '-j',
  305. '--maxjobs',
  306. action='store',
  307. type=int,
  308. help='The number of jobs to use when retrieving review status')
  309. parser.add_argument('--show-subject',
  310. action='store_true',
  311. dest='show_subject',
  312. help='Show the commit subject.')
  313. parser.add_argument('--hide-dormant',
  314. action='store_true',
  315. dest='hide_dormant',
  316. help='Hides dormant branches.')
  317. opts = parser.parse_args(argv)
  318. mapper = BranchMapper()
  319. mapper.verbosity = opts.v
  320. mapper.output.nocolor = opts.nocolor
  321. mapper.maxjobs = opts.maxjobs
  322. mapper.show_subject = opts.show_subject
  323. mapper.hide_dormant = opts.hide_dormant
  324. mapper.start()
  325. print(mapper.output.as_formatted_string())
  326. return 0
  327. if __name__ == '__main__':
  328. try:
  329. with metrics.collector.print_notice_and_exit():
  330. sys.exit(main(sys.argv[1:]))
  331. except KeyboardInterrupt:
  332. sys.stderr.write('interrupted\n')
  333. sys.exit(1)