blob: d06a4ceb9a8a3e9a95a3f48009258976926685da [file] [log] [blame]
Josip Sokcevic4de5dea2022-03-23 21:15:14 +00001#!/usr/bin/env python3
iannucci@chromium.orga112f032014-03-13 07:47:50 +00002# 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
anatoly techtonik222840f2017-04-15 16:25:57 +03006"""Print dependency tree of branches in local repo.
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +00007
calamity@chromium.org9d2c8802014-09-03 02:04:46 +00008Example:
Josip Sokcevic9c0dc302020-11-20 18:41:25 +00009origin/main
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +000010 cool_feature
11 dependent_feature
12 other_dependent_feature
13 other_feature
14
15Branches are colorized as follows:
16 * Red - a remote branch (usually the root of all local branches)
17 * Cyan - a local branch which is the same as HEAD
18 * Note that multiple branches may be Cyan, if they are all on the same
19 commit, and you have that commit checked out.
20 * Green - a local branch
calamity@chromium.org4cd0a8b2014-09-23 03:30:50 +000021 * Blue - a 'branch-heads' branch
iannucci@chromium.orgc050a5b2014-03-26 06:18:50 +000022 * Magenta - a tag
23 * Magenta '{NO UPSTREAM}' - If you have local branches which do not track any
24 upstream, then you will see this.
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +000025"""
iannucci@chromium.orgc050a5b2014-03-26 06:18:50 +000026
Raul Tambre80ee78e2019-05-06 22:41:05 +000027from __future__ import print_function
28
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000029import argparse
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +000030import collections
Josip Sokcevic306b03b2022-02-23 03:25:23 +000031import metrics
iannucci@chromium.org0703ea22016-04-01 01:02:42 +000032import os
calamity@chromium.org4cd0a8b2014-09-23 03:30:50 +000033import subprocess2
iannucci@chromium.org596cd5c2016-04-04 21:34:39 +000034import sys
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +000035
calamity@chromium.org745ffa62014-09-08 01:03:19 +000036from git_common import current_branch, upstream, tags, get_branches_info
iannucci@chromium.org4c82eb52014-09-08 02:12:24 +000037from git_common import get_git_version, MIN_UPSTREAM_TRACK_GIT_VERSION, hash_one
borenet@google.com09156ec2015-03-26 14:10:06 +000038from git_common import run
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +000039
iannucci@chromium.org596cd5c2016-04-04 21:34:39 +000040import setup_color
41
42from third_party.colorama import Fore, Style
43
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000044DEFAULT_SEPARATOR = ' ' * 4
iannucci@chromium.orgc050a5b2014-03-26 06:18:50 +000045
46
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000047class OutputManager(object):
48 """Manages a number of OutputLines and formats them into aligned columns."""
iannucci@chromium.orgc050a5b2014-03-26 06:18:50 +000049
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000050 def __init__(self):
51 self.lines = []
52 self.nocolor = False
53 self.max_column_lengths = []
54 self.num_columns = None
iannucci@chromium.orgc050a5b2014-03-26 06:18:50 +000055
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000056 def append(self, line):
57 # All lines must have the same number of columns.
58 if not self.num_columns:
59 self.num_columns = len(line.columns)
60 self.max_column_lengths = [0] * self.num_columns
61 assert self.num_columns == len(line.columns)
62
63 if self.nocolor:
64 line.colors = [''] * self.num_columns
65
66 self.lines.append(line)
67
68 # Update maximum column lengths.
69 for i, col in enumerate(line.columns):
70 self.max_column_lengths[i] = max(self.max_column_lengths[i], len(col))
71
Orr Bernsteinb7e16d22023-07-14 11:00:55 +000072 def merge(self, other):
73 for line in other.lines:
74 self.append(line)
75
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000076 def as_formatted_string(self):
77 return '\n'.join(
78 l.as_padded_string(self.max_column_lengths) for l in self.lines)
79
80
81class OutputLine(object):
82 """A single line of data.
83
84 This consists of an equal number of columns, colors and separators."""
85
86 def __init__(self):
87 self.columns = []
88 self.separators = []
89 self.colors = []
90
91 def append(self, data, separator=DEFAULT_SEPARATOR, color=Fore.WHITE):
92 self.columns.append(data)
93 self.separators.append(separator)
94 self.colors.append(color)
95
96 def as_padded_string(self, max_column_lengths):
97 """"Returns the data as a string with each column padded to
98 |max_column_lengths|."""
99 output_string = ''
100 for i, (color, data, separator) in enumerate(
101 zip(self.colors, self.columns, self.separators)):
102 if max_column_lengths[i] == 0:
103 continue
104
105 padding = (max_column_lengths[i] - len(data)) * ' '
106 output_string += color + data + padding + separator
107
108 return output_string.rstrip()
109
110
111class BranchMapper(object):
112 """A class which constructs output representing the tree's branch structure.
113
114 Attributes:
calamity@chromium.org745ffa62014-09-08 01:03:19 +0000115 __branches_info: a map of branches to their BranchesInfo objects which
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000116 consist of the branch hash, upstream and ahead/behind status.
117 __gone_branches: a set of upstreams which are not fetchable by git"""
118
119 def __init__(self):
120 self.verbosity = 0
calamity@chromium.orgffde55c2015-03-12 00:44:17 +0000121 self.maxjobs = 0
borenet@google.com09156ec2015-03-26 14:10:06 +0000122 self.show_subject = False
Orr Bernsteinb7e16d22023-07-14 11:00:55 +0000123 self.hide_dormant = False
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000124 self.output = OutputManager()
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000125 self.__gone_branches = set()
calamity@chromium.org745ffa62014-09-08 01:03:19 +0000126 self.__branches_info = None
127 self.__parent_map = collections.defaultdict(list)
128 self.__current_branch = None
129 self.__current_hash = None
130 self.__tag_set = None
calamity@chromium.orgffde55c2015-03-12 00:44:17 +0000131 self.__status_info = {}
calamity@chromium.org745ffa62014-09-08 01:03:19 +0000132
133 def start(self):
134 self.__branches_info = get_branches_info(
135 include_tracking_status=self.verbosity >= 1)
calamity@chromium.orgffde55c2015-03-12 00:44:17 +0000136 if (self.verbosity >= 2):
137 # Avoid heavy import unless necessary.
clemensh@chromium.orgcbd7dc32016-05-31 10:33:50 +0000138 from git_cl import get_cl_statuses, color_for_status, Changelist
calamity@chromium.orgffde55c2015-03-12 00:44:17 +0000139
clemensh@chromium.orgcbd7dc32016-05-31 10:33:50 +0000140 change_cls = [Changelist(branchref='refs/heads/'+b)
141 for b in self.__branches_info.keys() if b]
142 status_info = get_cl_statuses(change_cls,
calamity@chromium.orgffde55c2015-03-12 00:44:17 +0000143 fine_grained=self.verbosity > 2,
144 max_processes=self.maxjobs)
145
clemensh@chromium.orgcbd7dc32016-05-31 10:33:50 +0000146 # This is a blocking get which waits for the remote CL status to be
147 # retrieved.
148 for cl, status in status_info:
Andrii Shyshkalov1ee78cd2020-03-12 01:31:53 +0000149 self.__status_info[cl.GetBranch()] = (cl.GetIssueURL(short=True),
150 color_for_status(status), status)
calamity@chromium.orgffde55c2015-03-12 00:44:17 +0000151
calamity@chromium.org745ffa62014-09-08 01:03:19 +0000152 roots = set()
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000153
154 # A map of parents to a list of their children.
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000155 for branch, branch_info in self.__branches_info.items():
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000156 if not branch_info:
157 continue
158
159 parent = branch_info.upstream
Clemens Hammacher793183d2019-03-22 01:12:46 +0000160 if self.__check_cycle(branch):
161 continue
calamity@chromium.org4cd0a8b2014-09-23 03:30:50 +0000162 if not self.__branches_info[parent]:
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000163 branch_upstream = upstream(branch)
164 # If git can't find the upstream, mark the upstream as gone.
165 if branch_upstream:
166 parent = branch_upstream
167 else:
168 self.__gone_branches.add(parent)
calamity@chromium.org745ffa62014-09-08 01:03:19 +0000169 # A parent that isn't in the branches info is a root.
170 roots.add(parent)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000171
calamity@chromium.org745ffa62014-09-08 01:03:19 +0000172 self.__parent_map[parent].append(branch)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000173
174 self.__current_branch = current_branch()
iannucci@chromium.org4c82eb52014-09-08 02:12:24 +0000175 self.__current_hash = hash_one('HEAD', short=True)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000176 self.__tag_set = tags()
177
iannucci@chromium.org4c82eb52014-09-08 02:12:24 +0000178 if roots:
179 for root in sorted(roots):
Orr Bernsteinb7e16d22023-07-14 11:00:55 +0000180 self.__append_branch(root, self.output)
iannucci@chromium.org4c82eb52014-09-08 02:12:24 +0000181 else:
182 no_branches = OutputLine()
183 no_branches.append('No User Branches')
184 self.output.append(no_branches)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000185
Clemens Hammacher793183d2019-03-22 01:12:46 +0000186 def __check_cycle(self, branch):
187 # Maximum length of the cycle is `num_branches`. This limit avoids running
188 # into a cycle which does *not* contain `branch`.
189 num_branches = len(self.__branches_info)
190 cycle = [branch]
191 while len(cycle) < num_branches and self.__branches_info[cycle[-1]]:
192 parent = self.__branches_info[cycle[-1]].upstream
193 cycle.append(parent)
194 if parent == branch:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000195 print('Warning: Detected cycle in branches: {}'.format(
196 ' -> '.join(cycle)), file=sys.stderr)
Clemens Hammacher793183d2019-03-22 01:12:46 +0000197 return True
198 return False
199
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000200 def __is_invalid_parent(self, parent):
201 return not parent or parent in self.__gone_branches
202
203 def __color_for_branch(self, branch, branch_hash):
jsbell@google.com4f1fc352016-03-24 22:23:46 +0000204 if branch.startswith('origin/'):
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000205 color = Fore.RED
calamity@chromium.org4cd0a8b2014-09-23 03:30:50 +0000206 elif branch.startswith('branch-heads'):
207 color = Fore.BLUE
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000208 elif self.__is_invalid_parent(branch) or branch in self.__tag_set:
209 color = Fore.MAGENTA
iannucci@chromium.org4c82eb52014-09-08 02:12:24 +0000210 elif self.__current_hash.startswith(branch_hash):
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000211 color = Fore.CYAN
212 else:
213 color = Fore.GREEN
214
calamity@chromium.org4cd0a8b2014-09-23 03:30:50 +0000215 if branch_hash and self.__current_hash.startswith(branch_hash):
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000216 color += Style.BRIGHT
217 else:
218 color += Style.NORMAL
219
220 return color
221
Orr Bernsteinb7e16d22023-07-14 11:00:55 +0000222 def __is_dormant_branch(self, branch):
223 if '/' in branch:
224 return False
225
226 is_dormant = run('config',
227 '--get',
228 'branch.{}.dormant'.format(branch),
229 accepted_retcodes=[0, 1])
230 return is_dormant == 'true'
231
232 def __append_branch(self, branch, output, depth=0):
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000233 """Recurses through the tree structure and appends an OutputLine to the
234 OutputManager for each branch."""
Orr Bernsteinb7e16d22023-07-14 11:00:55 +0000235 child_output = OutputManager()
236 for child in sorted(self.__parent_map.pop(branch, ())):
237 self.__append_branch(child, child_output, depth=depth + 1)
238
239 is_dormant_branch = self.__is_dormant_branch(branch)
240 if self.hide_dormant and is_dormant_branch and not child_output.lines:
241 return
242
calamity@chromium.org745ffa62014-09-08 01:03:19 +0000243 branch_info = self.__branches_info[branch]
iannucci@chromium.org4c82eb52014-09-08 02:12:24 +0000244 if branch_info:
245 branch_hash = branch_info.hash
246 else:
calamity@chromium.org4cd0a8b2014-09-23 03:30:50 +0000247 try:
248 branch_hash = hash_one(branch, short=True)
249 except subprocess2.CalledProcessError:
250 branch_hash = None
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000251
252 line = OutputLine()
253
254 # The branch name with appropriate indentation.
255 suffix = ''
256 if branch == self.__current_branch or (
257 self.__current_branch == 'HEAD' and branch == self.__current_hash):
iannucci@chromium.orga112f032014-03-13 07:47:50 +0000258 suffix = ' *'
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000259 branch_string = branch
260 if branch in self.__gone_branches:
261 branch_string = '{%s:GONE}' % branch
262 if not branch:
263 branch_string = '{NO_UPSTREAM}'
264 main_string = ' ' * depth + branch_string + suffix
265 line.append(
266 main_string,
267 color=self.__color_for_branch(branch, branch_hash))
iannucci@chromium.orga112f032014-03-13 07:47:50 +0000268
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000269 # The branch hash.
270 if self.verbosity >= 2:
271 line.append(branch_hash or '', separator=' ', color=Fore.RED)
272
273 # The branch tracking status.
274 if self.verbosity >= 1:
Gavin Mak8d7201b2020-09-17 19:21:38 +0000275 commits_string = ''
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000276 behind_string = ''
277 front_separator = ''
278 center_separator = ''
279 back_separator = ''
280 if branch_info and not self.__is_invalid_parent(branch_info.upstream):
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000281 behind = branch_info.behind
Gavin Mak8d7201b2020-09-17 19:21:38 +0000282 commits = branch_info.commits
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000283
Gavin Mak8d7201b2020-09-17 19:21:38 +0000284 if commits:
285 commits_string = '%d commit' % commits
286 commits_string += 's' if commits > 1 else ' '
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000287 if behind:
288 behind_string = 'behind %d' % behind
289
Gavin Mak8d7201b2020-09-17 19:21:38 +0000290 if commits or behind:
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000291 front_separator = '['
292 back_separator = ']'
293
Gavin Mak8d7201b2020-09-17 19:21:38 +0000294 if commits and behind:
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000295 center_separator = '|'
296
297 line.append(front_separator, separator=' ')
Gavin Mak8d7201b2020-09-17 19:21:38 +0000298 line.append(commits_string, separator=' ', color=Fore.MAGENTA)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000299 line.append(center_separator, separator=' ')
300 line.append(behind_string, separator=' ', color=Fore.MAGENTA)
301 line.append(back_separator)
302
Orr Bernsteinb7e16d22023-07-14 11:00:55 +0000303 if self.verbosity >= 4:
304 line.append(' (dormant)' if is_dormant_branch else ' ',
305 separator=' ',
306 color=Fore.RED)
307
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000308 # The Rietveld issue associated with the branch.
309 if self.verbosity >= 2:
asanka97f39492016-07-18 18:16:40 -0700310 (url, color, status) = ('', '', '') if self.__is_invalid_parent(branch) \
311 else self.__status_info[branch]
312 if self.verbosity > 2:
313 line.append('{} ({})'.format(url, status) if url else '', color=color)
314 else:
315 line.append(url or '', color=color)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000316
borenet@google.com09156ec2015-03-26 14:10:06 +0000317 # The subject of the most recent commit on the branch.
318 if self.show_subject:
Christian Flacha2658212022-12-07 09:00:42 +0000319 if not self.__is_invalid_parent(branch):
Aaron Gable6761b9d2017-08-28 12:23:40 -0700320 line.append(run('log', '-n1', '--format=%s', branch, '--'))
321 else:
322 line.append('')
borenet@google.com09156ec2015-03-26 14:10:06 +0000323
Orr Bernsteinb7e16d22023-07-14 11:00:55 +0000324 output.append(line)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000325
Orr Bernsteinb7e16d22023-07-14 11:00:55 +0000326 output.merge(child_output)
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +0000327
328
anatoly techtonik222840f2017-04-15 16:25:57 +0300329def print_desc():
330 for line in __doc__.splitlines():
331 starpos = line.find('* ')
332 if starpos == -1 or '-' not in line:
333 print(line)
334 else:
335 _, color, rest = line.split(None, 2)
336 outline = line[:starpos+1]
337 outline += getattr(Fore, color.upper()) + " " + color + " " + Fore.RESET
338 outline += rest
339 print(outline)
340 print('')
341
Josip Sokcevic306b03b2022-02-23 03:25:23 +0000342@metrics.collector.collect_metrics('git map-branches')
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +0000343def main(argv):
iannucci@chromium.org596cd5c2016-04-04 21:34:39 +0000344 setup_color.init()
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000345 if get_git_version() < MIN_UPSTREAM_TRACK_GIT_VERSION:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000346 print(
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000347 'This tool will not show all tracking information for git version '
348 'earlier than ' +
349 '.'.join(str(x) for x in MIN_UPSTREAM_TRACK_GIT_VERSION) +
Raul Tambre80ee78e2019-05-06 22:41:05 +0000350 '. Please consider upgrading.', file=sys.stderr)
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +0000351
anatoly techtonik222840f2017-04-15 16:25:57 +0300352 if '-h' in argv:
353 print_desc()
354
355 parser = argparse.ArgumentParser()
Orr Bernsteinb7e16d22023-07-14 11:00:55 +0000356 parser.add_argument('-v',
357 action='count',
358 default=0,
Clemens Hammacher03640c72018-12-13 08:08:19 +0000359 help=('Pass once to show tracking info, '
360 'twice for hash and review url, '
Orr Bernsteinb7e16d22023-07-14 11:00:55 +0000361 'thrice for review status, '
362 'four times to mark dormant branches'))
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000363 parser.add_argument('--no-color', action='store_true', dest='nocolor',
364 help='Turn off colors.')
calamity@chromium.orgffde55c2015-03-12 00:44:17 +0000365 parser.add_argument(
366 '-j', '--maxjobs', action='store', type=int,
367 help='The number of jobs to use when retrieving review status')
borenet@google.com09156ec2015-03-26 14:10:06 +0000368 parser.add_argument('--show-subject', action='store_true',
369 dest='show_subject', help='Show the commit subject.')
Orr Bernsteinb7e16d22023-07-14 11:00:55 +0000370 parser.add_argument('--hide-dormant',
371 action='store_true',
372 dest='hide_dormant',
373 help='Hides dormant branches.')
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +0000374
sbc@chromium.org013731e2015-02-26 18:28:43 +0000375 opts = parser.parse_args(argv)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000376
377 mapper = BranchMapper()
378 mapper.verbosity = opts.v
379 mapper.output.nocolor = opts.nocolor
calamity@chromium.orgffde55c2015-03-12 00:44:17 +0000380 mapper.maxjobs = opts.maxjobs
borenet@google.com09156ec2015-03-26 14:10:06 +0000381 mapper.show_subject = opts.show_subject
Orr Bernsteinb7e16d22023-07-14 11:00:55 +0000382 mapper.hide_dormant = opts.hide_dormant
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000383 mapper.start()
Raul Tambre80ee78e2019-05-06 22:41:05 +0000384 print(mapper.output.as_formatted_string())
sbc@chromium.org013731e2015-02-26 18:28:43 +0000385 return 0
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +0000386
387if __name__ == '__main__':
sbc@chromium.org013731e2015-02-26 18:28:43 +0000388 try:
Josip Sokcevic306b03b2022-02-23 03:25:23 +0000389 with metrics.collector.print_notice_and_exit():
390 sys.exit(main(sys.argv[1:]))
sbc@chromium.org013731e2015-02-26 18:28:43 +0000391 except KeyboardInterrupt:
392 sys.stderr.write('interrupted\n')
393 sys.exit(1)