bisect-kit: add chromeos local build bisector

bisector: bisect_cros_repo.py
switcher: switch_cros_localbuild.py

BUG=chromium:776314
TEST=unittest and following commands

(Stupid example, to find the commit of container uprev)
$ ./bisect_cros_repo.py init --old 10580.0.0 --new R68-10584.0.0 --dut $DUT \
      --repo_dir $CHROMEOS_ROOT
$ ./bisect_cros_repo.py config switch ./switch_cros_localbuild.py
$ ./bisect_cros_repo.py config eval ssh $DUT \
      '[ "$(grep ARC_VERSION /etc/lsb-release | cut -d= -f2)" -lt 4721668 ]'
$ ./bisect_cros_repo.py run

Change-Id: I172c452198c4109cb44883e32ba5da67f1b5045c
Reviewed-on: https://chromium-review.googlesource.com/1018680
Commit-Ready: Kuang-che Wu <kcwu@chromium.org>
Tested-by: Kuang-che Wu <kcwu@chromium.org>
Reviewed-by: Chung-yih Wang <cywang@chromium.org>
diff --git a/bisect_kit/git_util.py b/bisect_kit/git_util.py
index 9868328..b2d2561 100644
--- a/bisect_kit/git_util.py
+++ b/bisect_kit/git_util.py
@@ -5,6 +5,7 @@
 
 from __future__ import print_function
 import logging
+import os
 import re
 import subprocess
 
@@ -67,6 +68,27 @@
     return False
 
 
+def is_ancestor_commit(git_repo, old, new):
+  """Determines `old` commit is ancestor of `new` commit.
+
+  Args:
+    git_repo: path of git repo.
+    old: the ancestor commit.
+    new: the descendant commit.
+
+  Returns:
+    True only if `old` is the ancestor of `new`. One commit is not considered
+    as ancestor of itself.
+  """
+  return util.check_output(
+      'git',
+      'rev-list',
+      '--ancestry-path',
+      '-1',
+      '%s..%s' % (old, new),
+      cwd=git_repo) != ''
+
+
 def get_revlist(git_repo, old, new):
   """Enumerates git commit between two revisions (inclusive).
 
@@ -114,3 +136,143 @@
   git_rev = util.check_output(*cmd, cwd=git_repo).strip()
   assert git_rev
   return git_rev
+
+
+class Diff(object):
+  """Class to describe the difference between git commits.
+
+  Attributes:
+    timestamp: commit timestamp
+    path: git repo path relative to project root
+    action: action to make the diff, possible value: CHECKOUT_TO, ADD, REMOVE.
+    git_rev: git commit hash
+  """
+  CHECKOUT_TO = 'checkout_to'
+  ADD = 'add'
+  REMOVE = 'remove'
+
+  def __init__(self, timestamp, path, action, git_rev=None):
+    self.timestamp = timestamp
+    self.path = path
+    self.action = action
+    self.git_rev = git_rev
+
+  def apply(self, base_dir):
+    """Applies the diff on disk.
+
+    Args:
+      base_dir: the project root where self.path is relative to
+    """
+    assert self.path
+    git_repo = os.path.join(base_dir, self.path)
+    if self.action == Diff.CHECKOUT_TO:
+      checkout_version(git_repo, self.git_rev)
+      return
+    if self.action in ['add', 'remove']:
+      raise NotImplementedError
+    assert 0
+
+  def summary(self, base_dir):
+    """Summary string of this diff.
+
+    Args:
+      base_dir: the project root where self.path is relative to
+    """
+    if self.action == Diff.CHECKOUT_TO:
+      git_repo = os.path.join(base_dir, self.path)
+      summary = get_commit_log(git_repo, self.git_rev).splitlines()[0]
+      return '%s %s %r' % (self.git_rev[:10], self.path, summary)
+    return '%s %s' % (self.action, self.path)
+
+  def __eq__(self, rhs):
+    return (self.timestamp == rhs.timestamp and self.path == rhs.path and
+            self.action == rhs.action and self.git_rev == rhs.git_rev)
+
+
+def get_difflist_between_two_commit(base_dir, path, old, new):
+  """Get difflist between (old, new].
+
+  Args:
+    base_dir: the project root
+    path: the path relative to the project root
+    old: old commit hash (exclusive)
+    new: new commit hash (inclusive)
+
+  Returns:
+    list of Diff objects
+  """
+  git_repo = os.path.join(base_dir, path)
+  assert old and new
+  assert old == new or is_ancestor_commit(git_repo, old, new)
+  difflist = []
+  # --first-parent is necessary for Android, see following link for more
+  # discussion.
+  # https://docs.google.com/document/d/1c8qiq14_ObRRjLT62sk9r5V5cyCGHX66dLYab4MVnks/edit#heading=h.n3i6mt2n6xuu
+  for line in util.check_output(
+      'git',
+      'rev-list',
+      '--timestamp',
+      '--reverse',
+      '--first-parent',
+      '%s..%s' % (old, new),
+      cwd=git_repo).splitlines():
+    timestamp, git_rev = line.split()
+    difflist.append(Diff(int(timestamp), path, Diff.CHECKOUT_TO, git_rev))
+
+  # bisect-kit has a fundamental assumption that commit timestamps are
+  # increasing because we sort and bisect the commits by timestamp across git
+  # repos. If not increasing, we have to adjust the timestamp as workaround.
+  # This might lead to bad bisect result, however the bad probability is low in
+  # practice since most machines' clocks are good enough.
+  if difflist != sorted(difflist, key=lambda x: x.timestamp):
+    logger.warning('Commit timestamps are not increasing')
+    last_timestamp = -1
+    adjusted = 0
+    for diff in difflist:
+      if diff.timestamp < last_timestamp:
+        diff.timestamp = last_timestamp
+        adjusted += 1
+
+      last_timestamp = diff.timestamp
+    logger.warning('%d timestamps adjusted', adjusted)
+
+  return difflist
+
+
+def get_difflist_between_two_set(base_dir, old_set, new_set):
+  result = []
+  for path in set(old_set) | set(new_set):
+    git_repo = os.path.join(base_dir, path)
+    if path in old_set and path in new_set:
+      old = old_set[path]
+      new = new_set[path]
+      if old == new:
+        # nochange, do nothing
+        pass
+      elif is_ancestor_commit(git_repo, old, new):
+        # normal case
+        for diff in get_difflist_between_two_commit(base_dir, path, old, new):
+          result.append(diff)
+      else:
+        # maybe switch branch?
+        # TODO(kcwu): handle discontinuous properly (crbug.com/827092)
+        logger.warning(
+            'Warning: dependency "%s" discontinuous. Not supported yet', path)
+        return []
+    elif path in old_set:
+      # remove dependency
+      # TODO(kcwu): handle removal properly (crbug.com/827092)
+      logger.warning('Warning: dependency "%s" was removed. Not supported yet',
+                     path)
+      return []
+    else:
+      assert path in new_set
+      # add dependency
+      # TODO(kcwu): handle addition properly (crbug.com/827092)
+      logger.warning('Warning: dependency "%s" was added. Not supported yet',
+                     path)
+      return []
+
+  result.sort(key=lambda diff: (diff.timestamp, diff.path))
+
+  return result