bisect-kit: changes caused by manifest/DEPS are atomic

Sometimes, one DEPS roll may cause changes across several repos. These
changes should be handled atomically.

BUG=b:147256554
TEST=./bisect_cr_localbuild_internal.py init --old 81.0.3995.0 --new 81.0.3999.0

Change-Id: I65a66a9a72535100b771d2d0fe56d90e61047c38
diff --git a/bisect_kit/codechange.py b/bisect_kit/codechange.py
index c9a3391..8af4b22 100644
--- a/bisect_kit/codechange.py
+++ b/bisect_kit/codechange.py
@@ -285,9 +285,12 @@
 class ActionGroup(object):
   """Atomic group of Action objects
 
-  This models atomic commits (for example, gerrit topic, or circular
-  CQ-DEPEND). Otherwise, one ActionGroup usually consists only one Action
-  object.
+  This models atomic actions, ex:
+    - repo added/removed in the same manifest commit
+    - commits appears at the same time due to repo add
+    - gerrit topic
+    - circular CQ-DEPEND (Cq-Depend)
+  Otherwise, one ActionGroup usually consists only one Action object.
   """
 
   def __init__(self, timestamp, comment=None):
@@ -606,7 +609,7 @@
     self.spec_manager = spec_manager
     self.code_storage = code_storage
 
-  def generate_actions_between_specs(self, prev_float, next_float):
+  def generate_action_groups_between_specs(self, prev_float, next_float):
     """Generates actions between two float specs.
 
     Args:
@@ -614,10 +617,13 @@
       next_float: end of spec object (inclusive)
 
     Returns:
-      list of Action object (unordered)
+      list of ActionGroup object (ordered)
     """
-    actions = []
+    groups = []
+    last_group = ActionGroup(next_float.timestamp)
     is_removed = set()
+    # Sort alphabetically, so parent directories are handled before children
+    # directories.
     for path in sorted(set(prev_float.entries) | set(next_float.entries)):
 
       # Add repo
@@ -627,7 +633,7 @@
         else:
           next_at = self.code_storage.get_rev_by_time(next_float, path,
                                                       next_float.timestamp)
-        actions.append(
+        last_group.add(
             GitAddRepo(next_float.timestamp, path, next_float[path].repo_url,
                        next_at))
         continue
@@ -644,9 +650,18 @@
         till_at = self.code_storage.get_rev_by_time(prev_float, path,
                                                     next_float.timestamp)
 
-        actions.extend(
-            self.code_storage.get_actions_between_two_commit(
-                prev_float, path, prev_at, till_at))
+        actions = self.code_storage.get_actions_between_two_commit(
+            prev_float, path, prev_at, till_at)
+
+        # Assume commits with the same timestamp as manifest/DEPS change are
+        # atomic.
+        if actions and actions[-1].timestamp == next_float.timestamp:
+          last_group.add(actions.pop())
+
+        for action in actions:
+          group = ActionGroup(action.timestamp)
+          group.add(action)
+          groups.append(group)
       else:
         prev_at = till_at = prev_float[path].at
 
@@ -657,18 +672,16 @@
         # remove repo
         next_at = None
         sub_repos = [p for p in prev_float.entries if p.startswith(path + '/')]
-        group = ActionGroup(next_float.timestamp, comment='remove %s' % path)
         # Remove deeper repo first
         for path2 in sorted(sub_repos, reverse=True):
-          group.add(GitRemoveRepo(next_float.timestamp, path2))
+          last_group.add(GitRemoveRepo(next_float.timestamp, path2))
           is_removed.add(path2)
-        group.add(GitRemoveRepo(next_float.timestamp, path))
+        last_group.add(GitRemoveRepo(next_float.timestamp, path))
         is_removed.add(path)
         for path2 in sorted(set(sub_repos) & set(next_float.entries)):
-          group.add(
+          last_group.add(
               GitAddRepo(next_float.timestamp, path2,
                          next_float[path2].repo_url, prev_float[path2].at))
-        actions.append(group)
 
       elif next_float[path].is_static():
         # pinned to certain commit on different branch
@@ -691,11 +704,14 @@
                                                     next_float.timestamp)
 
       if next_at and next_at != till_at:
-        actions.append(
+        last_group.add(
             GitCheckoutCommit(next_float.timestamp, path,
                               next_float[path].repo_url, next_at))
 
-    return actions
+    groups.sort(key=lambda x: x.timestamp)
+    if last_group.actions:
+      groups.append(last_group)
+    return groups
 
   def synthesize_fixed_spec(self, float_spec, timestamp):
     """Synthesizes fixed spec from float spec of given time.
@@ -718,27 +734,6 @@
     name = '%s@%s' % (float_spec.path, timestamp)
     return Spec(SPEC_FIXED, name, timestamp, float_spec.path, result)
 
-  def reorder_actions(self, actions):
-    """Reorder and cluster actions.
-
-    Args:
-      actions: list of Action or ActionGroup objects
-
-    Returns:
-      list of ActionGroup objects
-    """
-    # TODO(kcwu): support atomic commits across repos
-    actions.sort(key=lambda x: x.timestamp)
-    result = []
-    for action in actions:
-      if isinstance(action, ActionGroup):
-        group = action
-      else:
-        group = ActionGroup(action.timestamp)
-        group.add(action)
-      result.append(group)
-    return result
-
   def match_spec(self, target, specs, start_index=0):
     threshold = 3600
     # ideal_index is the index of last spec before target
@@ -861,15 +856,15 @@
 
     # step 2, synthesize all fixed specs in the range from float specs.
     specs = float_specs + [fixed_specs[-1]]
-    actions = []
+    action_groups = []
     logger.debug('len(specs)=%d', len(specs))
     for i in range(len(specs) - 1):
       prev_float = specs[i]
       next_float = specs[i + 1]
       logger.debug('[%d], between %s (%s) and %s (%s)', i, prev_float.name,
                    prev_float.timestamp, next_float.name, next_float.timestamp)
-      actions += self.generate_actions_between_specs(prev_float, next_float)
-    action_groups = self.reorder_actions(actions)
+      action_groups += self.generate_action_groups_between_specs(
+          prev_float, next_float)
 
     spec = self.synthesize_fixed_spec(float_specs[0], fixed_specs[0].timestamp)
     synthesized = [spec.copy()]