vcssync: context manager for temporarily mutating Git refs (bug 1357597); r?glob draft
authorGregory Szorc <gps@mozilla.com>
Wed, 13 Sep 2017 11:49:41 -0700
changeset 11663 2b2fd757c29130e8685df63f56794379f930b3f1
parent 11656 d3d395a1a8f954a0c45aefd1a3196e2212a3d0bd
child 11664 a7db7a031df5bc7f18d66fd31f673eda1d829105
push id1785
push usergszorc@mozilla.com
push dateFri, 15 Sep 2017 01:22:24 +0000
reviewersglob
bugs1357597
vcssync: context manager for temporarily mutating Git refs (bug 1357597); r?glob We often perform operations that want to temporarily mutate the state of a repo then restore the repo to a pristine state. Let's create a context manager to facilitate restoring refs on a Git repo. To implement this as robustly as possible, we add a "force-update" action to our update_git_refs() helper. We shouldn't need this because we have the old value available. But there is a very narrow race condition where things could fail. Given the nature of the code that is running, it seems prudent to eliminate as many failure scenarios as possible. MozReview-Commit-ID: CZpdJCEhYxm
vcssync/mozvcssync/gitutil.py
--- a/vcssync/mozvcssync/gitutil.py
+++ b/vcssync/mozvcssync/gitutil.py
@@ -1,21 +1,25 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 """Utility functions for performing various Git functionality."""
 
 from __future__ import absolute_import, unicode_literals
 
+import contextlib
 import logging
 import os
 import pipes
 import subprocess
 
+import dulwich.repo
+
+
 logger = logging.getLogger(__name__)
 
 
 class GitCommand(object):
     """Helper class for running git commands"""
 
     def __init__(self, repo_path, secret=None):
         """
@@ -68,26 +72,30 @@ def update_git_refs(repo, reason, *actio
     Accepts a ``dulwich.repo.Repo``, a bytes ``reason`` describing why this
     was done, and 1 or more tuples describing the update to perform. Tuples
     have the form:
 
     ('update', ref, new_id, old_id)
     ('create', ref, id)
     ('delete', ref, old_id)
     ('force-delete', ref)
+    ('force-update', ref, new_id)
     """
     assert isinstance(reason, bytes)
 
     commands = []
     for action in actions:
         if action[0] == 'update':
             # Destructing will raise if length isn't correct, which is
             # desired for error checking.
             cmd, ref, new, old = action
             commands.append(b'update %s\0%s\0%s' % (ref, new, old))
+        elif action[0] == 'force-update':
+            cmd, ref, new = action
+            commands.append(b'update %s\0%s' % (ref, new))
         elif action[0] == 'create':
             cmd, ref, new = action
             commands.append(b'create %s\0%s' % (ref, new))
         elif action[0] == 'delete':
             cmd, ref, old = action
             commands.append(b'delete %s\0%s' % (ref, old))
         elif action[0] == 'force-delete':
             cmd, ref = action
@@ -103,8 +111,54 @@ def update_git_refs(repo, reason, *actio
     for command in commands:
         p.stdin.write(command)
         p.stdin.write(b'\0')
     p.stdin.close()
     res = p.wait()
     # TODO could use a more rich exception type that captures output.
     if res:
         raise Exception('failed to update git refs')
+
+
+@contextlib.contextmanager
+def temporary_git_refs_mutations(repo, reflog_message=b'restore repo state'):
+    """Temporarily mutate Git refs during an active context manager.
+
+    When entered, the context manager takes a "snapshot" of the state of the
+    Git refs. When it exits, the Git refs are returned to their previous state.
+
+    The reflog records mutations that occurred. So any changes made when the
+    context manager is active can still be accessed until the reflog expires.
+
+    The ``repo`` argument can be a filesystem path to a repo or a
+    ``dulwich.repo.Repo`` instance.
+    """
+    if not isinstance(repo, dulwich.repo.Repo):
+        repo = dulwich.repo.Repo(repo)
+
+    old_refs = repo.get_refs()
+
+    try:
+        yield repo
+    finally:
+        new_refs = repo.get_refs()
+
+        old_keys = set(old_refs.keys())
+        new_keys = set(new_refs.keys())
+
+        actions = []
+
+        # Delete added refs.
+        for ref in sorted(new_keys - old_keys):
+            actions.append(('force-delete', ref))
+
+        # Restore deleted refs.
+        for ref in sorted(old_keys - new_keys):
+            actions.append(('create', ref, old_refs[ref]))
+
+        # Move any changed refs.
+        for ref in sorted(old_keys & new_keys):
+            if old_refs[ref] == new_refs[ref]:
+                continue
+
+            actions.append(('force-update', ref, old_refs[ref]))
+
+        update_git_refs(repo, reflog_message, *actions)