git_map_branches.py 14 KB

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