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 get_config, run
  31. import gclient_utils
  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],
  54. 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. """
  90. def __init__(self):
  91. self.verbosity = 0
  92. self.maxjobs = 0
  93. self.show_subject = False
  94. self.hide_dormant = False
  95. self.output = OutputManager()
  96. self.__gone_branches = set()
  97. self.__branches_info = None
  98. self.__parent_map = collections.defaultdict(list)
  99. self.__current_branch = None
  100. self.__current_hash = None
  101. self.__tag_set = None
  102. self.__status_info = {}
  103. def start(self):
  104. self.__branches_info = get_branches_info(
  105. include_tracking_status=self.verbosity >= 1)
  106. if (self.verbosity >= 2):
  107. # Avoid heavy import unless necessary.
  108. from git_cl import get_cl_statuses, color_for_status, Changelist
  109. change_cls = [
  110. Changelist(branchref='refs/heads/' + b)
  111. for b in self.__branches_info.keys() if b
  112. ]
  113. status_info = get_cl_statuses(change_cls,
  114. fine_grained=self.verbosity > 2,
  115. max_processes=self.maxjobs)
  116. # This is a blocking get which waits for the remote CL status to be
  117. # retrieved.
  118. for cl, status in status_info:
  119. self.__status_info[cl.GetBranch()] = (cl.GetIssueURL(
  120. short=True), color_for_status(status), status)
  121. roots = set()
  122. # A map of parents to a list of their children.
  123. for branch, branch_info in self.__branches_info.items():
  124. if not branch_info:
  125. continue
  126. if self.__check_cycle(branch):
  127. continue
  128. parent = branch_info.upstream
  129. if not self.__branches_info[parent]:
  130. # If the parent is not a known branch, it may be an upstream
  131. # branch like origin/main or it may be gone. Determine which it
  132. # is, but don't re-query the same parent multiple times.
  133. if parent not in roots:
  134. if not upstream(branch):
  135. self.__gone_branches.add(parent)
  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. return get_config(f'branch.{branch}.dormant') == 'true'
  184. def __append_branch(self, branch, output, depth=0):
  185. """Recurses through the tree structure and appends an OutputLine to the
  186. OutputManager for each branch."""
  187. child_output = OutputManager()
  188. for child in sorted(self.__parent_map.pop(branch, ())):
  189. self.__append_branch(child, child_output, depth=depth + 1)
  190. is_dormant_branch = self.__is_dormant_branch(branch)
  191. if self.hide_dormant and is_dormant_branch and not child_output.lines:
  192. return
  193. branch_info = self.__branches_info[branch]
  194. if branch_info:
  195. branch_hash = branch_info.hash
  196. else:
  197. try:
  198. branch_hash = hash_one(branch, short=True)
  199. except subprocess2.CalledProcessError:
  200. branch_hash = None
  201. line = OutputLine()
  202. # The branch name with appropriate indentation.
  203. suffix = ''
  204. if branch == self.__current_branch or (self.__current_branch == 'HEAD'
  205. and branch
  206. == 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(main_string,
  215. color=self.__color_for_branch(branch, branch_hash))
  216. # The branch hash.
  217. if self.verbosity >= 2:
  218. line.append(branch_hash or '', separator=' ', color=Fore.RED)
  219. # The branch tracking status.
  220. if self.verbosity >= 1:
  221. commits_string = ''
  222. behind_string = ''
  223. front_separator = ''
  224. center_separator = ''
  225. back_separator = ''
  226. if branch_info and not self.__is_invalid_parent(
  227. 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,
  252. status) = (('', '', '') if self.__is_invalid_parent(branch) else
  253. self.__status_info[branch])
  254. if self.verbosity > 2:
  255. line.append('{} ({})'.format(url, status) if url else '',
  256. color=color)
  257. else:
  258. line.append(url or '', color=color)
  259. # The subject of the most recent commit on the branch.
  260. if self.show_subject:
  261. if not self.__is_invalid_parent(branch):
  262. line.append(run('log', '-n1', '--format=%s', branch, '--'))
  263. else:
  264. line.append('')
  265. output.append(line)
  266. output.merge(child_output)
  267. def print_desc():
  268. for line in __doc__.splitlines():
  269. starpos = line.find('* ')
  270. if starpos == -1 or '-' not in line:
  271. print(line)
  272. else:
  273. _, color, rest = line.split(None, 2)
  274. outline = line[:starpos + 1]
  275. outline += getattr(Fore,
  276. color.upper()) + " " + color + " " + Fore.RESET
  277. outline += rest
  278. print(outline)
  279. print('')
  280. @metrics.collector.collect_metrics('git map-branches')
  281. def main(argv):
  282. if gclient_utils.IsEnvCog():
  283. print('map-branches command is not supported in non-git environment.',
  284. file=sys.stderr)
  285. return 1
  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)