git_map_branches.py 10 KB
Newer Older
1
#!/usr/bin/env python
2 3 4 5
# Copyright 2014 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

6 7
"""Provides a short mapping of all the branches in your local repo, organized
by their upstream ('tracking branch') layout.
8

9
Example:
10 11 12 13 14 15 16 17 18 19 20 21
origin/master
  cool_feature
    dependent_feature
    other_dependent_feature
  other_feature

Branches are colorized as follows:
  * Red - a remote branch (usually the root of all local branches)
  * Cyan - a local branch which is the same as HEAD
    * Note that multiple branches may be Cyan, if they are all on the same
      commit, and you have that commit checked out.
  * Green - a local branch
22
  * Blue - a 'branch-heads' branch
23 24 25
  * Magenta - a tag
  * Magenta '{NO UPSTREAM}' - If you have local branches which do not track any
    upstream, then you will see this.
26
"""
27

28
import argparse
29
import collections
30
import os
31
import subprocess2
32
import sys
33

34
from git_common import current_branch, upstream, tags, get_branches_info
35
from git_common import get_git_version, MIN_UPSTREAM_TRACK_GIT_VERSION, hash_one
36
from git_common import run
37

38 39 40 41
import setup_color

from third_party.colorama import Fore, Style

42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102
DEFAULT_SEPARATOR = ' ' * 4


class OutputManager(object):
  """Manages a number of OutputLines and formats them into aligned columns."""

  def __init__(self):
    self.lines = []
    self.nocolor = False
    self.max_column_lengths = []
    self.num_columns = None

  def append(self, line):
    # All lines must have the same number of columns.
    if not self.num_columns:
      self.num_columns = len(line.columns)
      self.max_column_lengths = [0] * self.num_columns
    assert self.num_columns == len(line.columns)

    if self.nocolor:
      line.colors = [''] * self.num_columns

    self.lines.append(line)

    # Update maximum column lengths.
    for i, col in enumerate(line.columns):
      self.max_column_lengths[i] = max(self.max_column_lengths[i], len(col))

  def as_formatted_string(self):
    return '\n'.join(
        l.as_padded_string(self.max_column_lengths) for l in self.lines)


class OutputLine(object):
  """A single line of data.

  This consists of an equal number of columns, colors and separators."""

  def __init__(self):
    self.columns = []
    self.separators = []
    self.colors = []

  def append(self, data, separator=DEFAULT_SEPARATOR, color=Fore.WHITE):
    self.columns.append(data)
    self.separators.append(separator)
    self.colors.append(color)

  def as_padded_string(self, max_column_lengths):
    """"Returns the data as a string with each column padded to
    |max_column_lengths|."""
    output_string = ''
    for i, (color, data, separator) in enumerate(
        zip(self.colors, self.columns, self.separators)):
      if max_column_lengths[i] == 0:
        continue

      padding = (max_column_lengths[i] - len(data)) * ' '
      output_string += color + data + padding + separator

    return output_string.rstrip()
103 104


105 106
class BranchMapper(object):
  """A class which constructs output representing the tree's branch structure.
107

108
  Attributes:
109
    __branches_info: a map of branches to their BranchesInfo objects which
110 111
      consist of the branch hash, upstream and ahead/behind status.
    __gone_branches: a set of upstreams which are not fetchable by git"""
112

113 114
  def __init__(self):
    self.verbosity = 0
115
    self.maxjobs = 0
116
    self.show_subject = False
117 118
    self.output = OutputManager()
    self.__gone_branches = set()
119 120 121 122 123
    self.__branches_info = None
    self.__parent_map = collections.defaultdict(list)
    self.__current_branch = None
    self.__current_hash = None
    self.__tag_set = None
124
    self.__status_info = {}
125 126 127 128

  def start(self):
    self.__branches_info = get_branches_info(
        include_tracking_status=self.verbosity >= 1)
129 130
    if (self.verbosity >= 2):
      # Avoid heavy import unless necessary.
131
      from git_cl import get_cl_statuses, color_for_status, Changelist
132

133 134 135
      change_cls = [Changelist(branchref='refs/heads/'+b)
                    for b in self.__branches_info.keys() if b]
      status_info = get_cl_statuses(change_cls,
136 137 138
                                    fine_grained=self.verbosity > 2,
                                    max_processes=self.maxjobs)

139 140 141 142
      # This is a blocking get which waits for the remote CL status to be
      # retrieved.
      for cl, status in status_info:
        self.__status_info[cl.GetBranch()] = (cl.GetIssueURL(),
143 144
                                              color_for_status(status),
                                              status)
145

146
    roots = set()
147

148
    # A map of parents to a list of their children.
149
    for branch, branch_info in self.__branches_info.iteritems():
150 151
      if not branch_info:
        continue
152

153
      parent = branch_info.upstream
154
      if not self.__branches_info[parent]:
155 156 157 158 159 160
        branch_upstream = upstream(branch)
        # If git can't find the upstream, mark the upstream as gone.
        if branch_upstream:
          parent = branch_upstream
        else:
          self.__gone_branches.add(parent)
161 162
        # A parent that isn't in the branches info is a root.
        roots.add(parent)
163

164
      self.__parent_map[parent].append(branch)
165

166
    self.__current_branch = current_branch()
167
    self.__current_hash = hash_one('HEAD', short=True)
168 169
    self.__tag_set = tags()

170 171 172 173 174 175 176
    if roots:
      for root in sorted(roots):
        self.__append_branch(root)
    else:
      no_branches = OutputLine()
      no_branches.append('No User Branches')
      self.output.append(no_branches)
177 178 179 180 181

  def __is_invalid_parent(self, parent):
    return not parent or parent in self.__gone_branches

  def __color_for_branch(self, branch, branch_hash):
182
    if branch.startswith('origin/'):
183
      color = Fore.RED
184 185
    elif branch.startswith('branch-heads'):
      color = Fore.BLUE
186 187
    elif self.__is_invalid_parent(branch) or branch in self.__tag_set:
      color = Fore.MAGENTA
188
    elif self.__current_hash.startswith(branch_hash):
189 190 191 192
      color = Fore.CYAN
    else:
      color = Fore.GREEN

193
    if branch_hash and self.__current_hash.startswith(branch_hash):
194 195 196 197 198 199 200 201 202
      color += Style.BRIGHT
    else:
      color += Style.NORMAL

    return color

  def __append_branch(self, branch, depth=0):
    """Recurses through the tree structure and appends an OutputLine to the
    OutputManager for each branch."""
203
    branch_info = self.__branches_info[branch]
204 205 206
    if branch_info:
      branch_hash = branch_info.hash
    else:
207 208 209 210
      try:
        branch_hash = hash_one(branch, short=True)
      except subprocess2.CalledProcessError:
        branch_hash = None
211 212 213 214 215 216 217

    line = OutputLine()

    # The branch name with appropriate indentation.
    suffix = ''
    if branch == self.__current_branch or (
        self.__current_branch == 'HEAD' and branch == self.__current_hash):
218
      suffix = ' *'
219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242
    branch_string = branch
    if branch in self.__gone_branches:
      branch_string = '{%s:GONE}' % branch
    if not branch:
      branch_string = '{NO_UPSTREAM}'
    main_string = '  ' * depth + branch_string + suffix
    line.append(
        main_string,
        color=self.__color_for_branch(branch, branch_hash))

    # The branch hash.
    if self.verbosity >= 2:
      line.append(branch_hash or '', separator=' ', color=Fore.RED)

    # The branch tracking status.
    if self.verbosity >= 1:
      ahead_string = ''
      behind_string = ''
      front_separator = ''
      center_separator = ''
      back_separator = ''
      if branch_info and not self.__is_invalid_parent(branch_info.upstream):
        ahead = branch_info.ahead
        behind = branch_info.behind
243

244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263
        if ahead:
          ahead_string = 'ahead %d' % ahead
        if behind:
          behind_string = 'behind %d' % behind

        if ahead or behind:
          front_separator = '['
          back_separator = ']'

        if ahead and behind:
          center_separator = '|'

      line.append(front_separator, separator=' ')
      line.append(ahead_string, separator=' ', color=Fore.MAGENTA)
      line.append(center_separator, separator=' ')
      line.append(behind_string, separator=' ', color=Fore.MAGENTA)
      line.append(back_separator)

    # The Rietveld issue associated with the branch.
    if self.verbosity >= 2:
264 265 266 267 268 269
      (url, color, status) = ('', '', '') if self.__is_invalid_parent(branch) \
          else self.__status_info[branch]
      if self.verbosity > 2:
        line.append('{} ({})'.format(url, status) if url else '', color=color)
      else:
        line.append(url or '', color=color)
270

271 272
    # The subject of the most recent commit on the branch.
    if self.show_subject:
273
      line.append(run('log', '-n1', '--format=%s', branch, '--'))
274

275 276
    self.output.append(line)

277
    for child in sorted(self.__parent_map.pop(branch, ())):
278
      self.__append_branch(child, depth=depth + 1)
279 280 281


def main(argv):
282
  setup_color.init()
283 284 285 286 287 288 289 290 291 292 293 294 295
  if get_git_version() < MIN_UPSTREAM_TRACK_GIT_VERSION:
    print >> sys.stderr, (
        'This tool will not show all tracking information for git version '
        'earlier than ' +
        '.'.join(str(x) for x in MIN_UPSTREAM_TRACK_GIT_VERSION) +
        '. Please consider upgrading.')

  parser = argparse.ArgumentParser(
      description='Print a a tree of all branches parented by their upstreams')
  parser.add_argument('-v', action='count',
                      help='Display branch hash and Rietveld URL')
  parser.add_argument('--no-color', action='store_true', dest='nocolor',
                      help='Turn off colors.')
296 297 298
  parser.add_argument(
      '-j', '--maxjobs', action='store', type=int,
      help='The number of jobs to use when retrieving review status')
299 300
  parser.add_argument('--show-subject', action='store_true',
                      dest='show_subject', help='Show the commit subject.')
301

302
  opts = parser.parse_args(argv)
303 304 305 306

  mapper = BranchMapper()
  mapper.verbosity = opts.v
  mapper.output.nocolor = opts.nocolor
307
  mapper.maxjobs = opts.maxjobs
308
  mapper.show_subject = opts.show_subject
309 310
  mapper.start()
  print mapper.output.as_formatted_string()
311
  return 0
312 313

if __name__ == '__main__':
314 315 316 317 318
  try:
    sys.exit(main(sys.argv[1:]))
  except KeyboardInterrupt:
    sys.stderr.write('interrupted\n')
    sys.exit(1)