blob: eb68074195a733e272c2b20bf21f5a8325659ed4 [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.
anatoly techtonik222840f2017-04-15 16:25:57 +03005"""Print dependency tree of branches in local repo.
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +00006
calamity@chromium.org9d2c8802014-09-03 02:04:46 +00007Example:
Josip Sokcevic9c0dc302020-11-20 18:41:25 +00008origin/main
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +00009 cool_feature
10 dependent_feature
11 other_dependent_feature
12 other_feature
13
14Branches are colorized as follows:
15 * Red - a remote branch (usually the root of all local branches)
16 * Cyan - a local branch which is the same as HEAD
17 * Note that multiple branches may be Cyan, if they are all on the same
18 commit, and you have that commit checked out.
19 * Green - a local branch
calamity@chromium.org4cd0a8b2014-09-23 03:30:50 +000020 * Blue - a 'branch-heads' branch
iannucci@chromium.orgc050a5b2014-03-26 06:18:50 +000021 * Magenta - a tag
22 * Magenta '{NO UPSTREAM}' - If you have local branches which do not track any
23 upstream, then you will see this.
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +000024"""
iannucci@chromium.orgc050a5b2014-03-26 06:18:50 +000025
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000026import argparse
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +000027import collections
Josip Sokcevic306b03b2022-02-23 03:25:23 +000028import metrics
calamity@chromium.org4cd0a8b2014-09-23 03:30:50 +000029import subprocess2
iannucci@chromium.org596cd5c2016-04-04 21:34:39 +000030import sys
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +000031
calamity@chromium.org745ffa62014-09-08 01:03:19 +000032from git_common import current_branch, upstream, tags, get_branches_info
iannucci@chromium.org4c82eb52014-09-08 02:12:24 +000033from git_common import get_git_version, MIN_UPSTREAM_TRACK_GIT_VERSION, hash_one
borenet@google.com09156ec2015-03-26 14:10:06 +000034from git_common import run
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +000035
iannucci@chromium.org596cd5c2016-04-04 21:34:39 +000036import setup_color
37
38from third_party.colorama import Fore, Style
39
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000040DEFAULT_SEPARATOR = ' ' * 4
iannucci@chromium.orgc050a5b2014-03-26 06:18:50 +000041
42
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000043class OutputManager(object):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000044 """Manages a number of OutputLines and formats them into aligned columns."""
45 def __init__(self):
46 self.lines = []
47 self.nocolor = False
48 self.max_column_lengths = []
49 self.num_columns = None
iannucci@chromium.orgc050a5b2014-03-26 06:18:50 +000050
Mike Frysinger124bb8e2023-09-06 05:48:55 +000051 def append(self, line):
52 # All lines must have the same number of columns.
53 if not self.num_columns:
54 self.num_columns = len(line.columns)
55 self.max_column_lengths = [0] * self.num_columns
56 assert self.num_columns == len(line.columns)
iannucci@chromium.orgc050a5b2014-03-26 06:18:50 +000057
Mike Frysinger124bb8e2023-09-06 05:48:55 +000058 if self.nocolor:
59 line.colors = [''] * self.num_columns
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000060
Mike Frysinger124bb8e2023-09-06 05:48:55 +000061 self.lines.append(line)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000062
Mike Frysinger124bb8e2023-09-06 05:48:55 +000063 # Update maximum column lengths.
64 for i, col in enumerate(line.columns):
65 self.max_column_lengths[i] = max(self.max_column_lengths[i],
66 len(col))
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000067
Mike Frysinger124bb8e2023-09-06 05:48:55 +000068 def merge(self, other):
69 for line in other.lines:
70 self.append(line)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000071
Mike Frysinger124bb8e2023-09-06 05:48:55 +000072 def as_formatted_string(self):
73 return '\n'.join(
74 l.as_padded_string(self.max_column_lengths) for l in self.lines)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000075
76
77class OutputLine(object):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000078 """A single line of data.
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000079
80 This consists of an equal number of columns, colors and separators."""
Mike Frysinger124bb8e2023-09-06 05:48:55 +000081 def __init__(self):
82 self.columns = []
83 self.separators = []
84 self.colors = []
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000085
Mike Frysinger124bb8e2023-09-06 05:48:55 +000086 def append(self, data, separator=DEFAULT_SEPARATOR, color=Fore.WHITE):
87 self.columns.append(data)
88 self.separators.append(separator)
89 self.colors.append(color)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000090
Mike Frysinger124bb8e2023-09-06 05:48:55 +000091 def as_padded_string(self, max_column_lengths):
92 """"Returns the data as a string with each column padded to
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000093 |max_column_lengths|."""
Mike Frysinger124bb8e2023-09-06 05:48:55 +000094 output_string = ''
95 for i, (color, data, separator) in enumerate(
96 zip(self.colors, self.columns, self.separators)):
97 if max_column_lengths[i] == 0:
98 continue
calamity@chromium.org9d2c8802014-09-03 02:04:46 +000099
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000100 padding = (max_column_lengths[i] - len(data)) * ' '
101 output_string += color + data + padding + separator
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000102
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000103 return output_string.rstrip()
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000104
105
106class BranchMapper(object):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000107 """A class which constructs output representing the tree's branch structure.
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000108
109 Attributes:
calamity@chromium.org745ffa62014-09-08 01:03:19 +0000110 __branches_info: a map of branches to their BranchesInfo objects which
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000111 consist of the branch hash, upstream and ahead/behind status.
112 __gone_branches: a set of upstreams which are not fetchable by git"""
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000113 def __init__(self):
114 self.verbosity = 0
115 self.maxjobs = 0
116 self.show_subject = False
117 self.hide_dormant = False
118 self.output = OutputManager()
119 self.__gone_branches = set()
120 self.__branches_info = None
121 self.__parent_map = collections.defaultdict(list)
122 self.__current_branch = None
123 self.__current_hash = None
124 self.__tag_set = None
125 self.__status_info = {}
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000126
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000127 def start(self):
128 self.__branches_info = get_branches_info(
129 include_tracking_status=self.verbosity >= 1)
130 if (self.verbosity >= 2):
131 # Avoid heavy import unless necessary.
132 from git_cl import get_cl_statuses, color_for_status, Changelist
calamity@chromium.org745ffa62014-09-08 01:03:19 +0000133
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000134 change_cls = [
135 Changelist(branchref='refs/heads/' + b)
136 for b in self.__branches_info.keys() if b
137 ]
138 status_info = get_cl_statuses(change_cls,
139 fine_grained=self.verbosity > 2,
140 max_processes=self.maxjobs)
calamity@chromium.orgffde55c2015-03-12 00:44:17 +0000141
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000142 # This is a blocking get which waits for the remote CL status to be
143 # retrieved.
144 for cl, status in status_info:
145 self.__status_info[cl.GetBranch()] = (cl.GetIssueURL(
146 short=True), color_for_status(status), status)
calamity@chromium.orgffde55c2015-03-12 00:44:17 +0000147
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000148 roots = set()
calamity@chromium.orgffde55c2015-03-12 00:44:17 +0000149
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000150 # A map of parents to a list of their children.
151 for branch, branch_info in self.__branches_info.items():
152 if not branch_info:
153 continue
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000154
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000155 parent = branch_info.upstream
156 if self.__check_cycle(branch):
157 continue
158 if not self.__branches_info[parent]:
159 branch_upstream = upstream(branch)
160 # If git can't find the upstream, mark the upstream as gone.
161 if branch_upstream:
162 parent = branch_upstream
163 else:
164 self.__gone_branches.add(parent)
165 # A parent that isn't in the branches info is a root.
166 roots.add(parent)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000167
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000168 self.__parent_map[parent].append(branch)
169
170 self.__current_branch = current_branch()
171 self.__current_hash = hash_one('HEAD', short=True)
172 self.__tag_set = tags()
173
174 if roots:
175 for root in sorted(roots):
176 self.__append_branch(root, self.output)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000177 else:
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000178 no_branches = OutputLine()
179 no_branches.append('No User Branches')
180 self.output.append(no_branches)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000181
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000182 def __check_cycle(self, branch):
183 # Maximum length of the cycle is `num_branches`. This limit avoids
184 # running into a cycle which does *not* contain `branch`.
185 num_branches = len(self.__branches_info)
186 cycle = [branch]
187 while len(cycle) < num_branches and self.__branches_info[cycle[-1]]:
188 parent = self.__branches_info[cycle[-1]].upstream
189 cycle.append(parent)
190 if parent == branch:
191 print('Warning: Detected cycle in branches: {}'.format(
192 ' -> '.join(cycle)),
193 file=sys.stderr)
194 return True
195 return False
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000196
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000197 def __is_invalid_parent(self, parent):
198 return not parent or parent in self.__gone_branches
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000199
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000200 def __color_for_branch(self, branch, branch_hash):
201 if branch.startswith('origin/'):
202 color = Fore.RED
203 elif branch.startswith('branch-heads'):
204 color = Fore.BLUE
205 elif self.__is_invalid_parent(branch) or branch in self.__tag_set:
206 color = Fore.MAGENTA
207 elif self.__current_hash.startswith(branch_hash):
208 color = Fore.CYAN
209 else:
210 color = Fore.GREEN
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000211
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000212 if branch_hash and self.__current_hash.startswith(branch_hash):
213 color += Style.BRIGHT
214 else:
215 color += Style.NORMAL
Clemens Hammacher793183d2019-03-22 01:12:46 +0000216
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000217 return color
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000218
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000219 def __is_dormant_branch(self, branch):
220 if '/' in branch:
221 return False
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000222
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000223 is_dormant = run('config',
224 '--get',
225 'branch.{}.dormant'.format(branch),
226 accepted_retcodes=[0, 1])
227 return is_dormant == 'true'
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000228
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000229 def __append_branch(self, branch, output, depth=0):
230 """Recurses through the tree structure and appends an OutputLine to the
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000231 OutputManager for each branch."""
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000232 child_output = OutputManager()
233 for child in sorted(self.__parent_map.pop(branch, ())):
234 self.__append_branch(child, child_output, depth=depth + 1)
Orr Bernsteinb7e16d22023-07-14 11:00:55 +0000235
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000236 is_dormant_branch = self.__is_dormant_branch(branch)
237 if self.hide_dormant and is_dormant_branch and not child_output.lines:
238 return
Orr Bernsteinb7e16d22023-07-14 11:00:55 +0000239
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000240 branch_info = self.__branches_info[branch]
241 if branch_info:
242 branch_hash = branch_info.hash
243 else:
244 try:
245 branch_hash = hash_one(branch, short=True)
246 except subprocess2.CalledProcessError:
247 branch_hash = None
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000248
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000249 line = OutputLine()
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000250
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000251 # The branch name with appropriate indentation.
252 suffix = ''
253 if branch == self.__current_branch or (self.__current_branch == 'HEAD'
254 and branch
255 == self.__current_hash):
256 suffix = ' *'
257 branch_string = branch
258 if branch in self.__gone_branches:
259 branch_string = '{%s:GONE}' % branch
260 if not branch:
261 branch_string = '{NO_UPSTREAM}'
262 main_string = ' ' * depth + branch_string + suffix
263 line.append(main_string,
264 color=self.__color_for_branch(branch, branch_hash))
iannucci@chromium.orga112f032014-03-13 07:47:50 +0000265
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000266 # The branch hash.
267 if self.verbosity >= 2:
268 line.append(branch_hash or '', separator=' ', color=Fore.RED)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000269
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000270 # The branch tracking status.
271 if self.verbosity >= 1:
272 commits_string = ''
273 behind_string = ''
274 front_separator = ''
275 center_separator = ''
276 back_separator = ''
277 if branch_info and not self.__is_invalid_parent(
278 branch_info.upstream):
279 behind = branch_info.behind
280 commits = branch_info.commits
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000281
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000282 if commits:
283 commits_string = '%d commit' % commits
284 commits_string += 's' if commits > 1 else ' '
285 if behind:
286 behind_string = 'behind %d' % behind
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000287
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000288 if commits or behind:
289 front_separator = '['
290 back_separator = ']'
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000291
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000292 if commits and behind:
293 center_separator = '|'
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000294
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000295 line.append(front_separator, separator=' ')
296 line.append(commits_string, separator=' ', color=Fore.MAGENTA)
297 line.append(center_separator, separator=' ')
298 line.append(behind_string, separator=' ', color=Fore.MAGENTA)
299 line.append(back_separator)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000300
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000301 if self.verbosity >= 4:
302 line.append(' (dormant)' if is_dormant_branch else ' ',
303 separator=' ',
304 color=Fore.RED)
Orr Bernsteinb7e16d22023-07-14 11:00:55 +0000305
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000306 # The Rietveld issue associated with the branch.
307 if self.verbosity >= 2:
308 (url, color,
309 status) = (('', '', '') if self.__is_invalid_parent(branch) else
310 self.__status_info[branch])
311 if self.verbosity > 2:
312 line.append('{} ({})'.format(url, status) if url else '',
313 color=color)
314 else:
315 line.append(url or '', color=color)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000316
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000317 # The subject of the most recent commit on the branch.
318 if self.show_subject:
319 if not self.__is_invalid_parent(branch):
320 line.append(run('log', '-n1', '--format=%s', branch, '--'))
321 else:
322 line.append('')
borenet@google.com09156ec2015-03-26 14:10:06 +0000323
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000324 output.append(line)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000325
Mike Frysinger124bb8e2023-09-06 05:48: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():
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000330 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,
338 color.upper()) + " " + color + " " + Fore.RESET
339 outline += rest
340 print(outline)
341 print('')
342
anatoly techtonik222840f2017-04-15 16:25:57 +0300343
Josip Sokcevic306b03b2022-02-23 03:25:23 +0000344@metrics.collector.collect_metrics('git map-branches')
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +0000345def main(argv):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000346 setup_color.init()
347 if get_git_version() < MIN_UPSTREAM_TRACK_GIT_VERSION:
348 print(
349 'This tool will not show all tracking information for git version '
350 'earlier than ' +
351 '.'.join(str(x) for x in MIN_UPSTREAM_TRACK_GIT_VERSION) +
352 '. Please consider upgrading.',
353 file=sys.stderr)
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +0000354
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000355 if '-h' in argv:
356 print_desc()
anatoly techtonik222840f2017-04-15 16:25:57 +0300357
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000358 parser = argparse.ArgumentParser()
359 parser.add_argument('-v',
360 action='count',
361 default=0,
362 help=('Pass once to show tracking info, '
363 'twice for hash and review url, '
364 'thrice for review status, '
365 'four times to mark dormant branches'))
366 parser.add_argument('--no-color',
367 action='store_true',
368 dest='nocolor',
369 help='Turn off colors.')
370 parser.add_argument(
371 '-j',
372 '--maxjobs',
373 action='store',
374 type=int,
375 help='The number of jobs to use when retrieving review status')
376 parser.add_argument('--show-subject',
377 action='store_true',
378 dest='show_subject',
379 help='Show the commit subject.')
380 parser.add_argument('--hide-dormant',
381 action='store_true',
382 dest='hide_dormant',
383 help='Hides dormant branches.')
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +0000384
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000385 opts = parser.parse_args(argv)
calamity@chromium.org9d2c8802014-09-03 02:04:46 +0000386
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000387 mapper = BranchMapper()
388 mapper.verbosity = opts.v
389 mapper.output.nocolor = opts.nocolor
390 mapper.maxjobs = opts.maxjobs
391 mapper.show_subject = opts.show_subject
392 mapper.hide_dormant = opts.hide_dormant
393 mapper.start()
394 print(mapper.output.as_formatted_string())
395 return 0
396
iannucci@chromium.org8bc9b5c2014-03-12 01:36:18 +0000397
398if __name__ == '__main__':
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000399 try:
400 with metrics.collector.print_notice_and_exit():
401 sys.exit(main(sys.argv[1:]))
402 except KeyboardInterrupt:
403 sys.stderr.write('interrupted\n')
404 sys.exit(1)