Utilize non-bare mirrored git clone
authorMarkus Lehtonen <markus.lehtonen@linux.intel.com>
Thu, 28 Mar 2013 07:46:09 +0000 (09:46 +0200)
committerMarkus Lehtonen <markus.lehtonen@linux.intel.com>
Fri, 5 Apr 2013 08:30:44 +0000 (11:30 +0300)
Makes submodules available. Also, makes it possible to utilize the
cached repositories for GBS.

Instead of a bare mirrored clone, create a special clone that mirrors
all remote refs, and, has a working copy. In short:
- create a git config that mirrors all remote refs directly, we don't
  expect any local changes or refs
- always fetch HEAD of remote repo
- when updating from remote, do a fetch with force and prune so that we
  stay in sync with the remote

Leaves the support for bare (mirrored) repos into CachedRepo class,
although this is not used by the service, currently.

Change-Id: I1496425b50a990aa83b5f08a03eeacafb074bb7b
Signed-off-by: Markus Lehtonen <markus.lehtonen@linux.intel.com>
obs_service_gbp/__init__.py
obs_service_gbp/command.py
tests/test_obs_service_gbp.py

index ca2d871da299d2c36c3864179db6430bde645d83..5d92216e23e90faaa4765fe474f60b4638b66e9c 100644 (file)
@@ -32,6 +32,47 @@ from gbp.git.repository import GitRepository, GitRepositoryError
 logger = gbplog.getLogger('source_service')
 logger.setLevel(gbplog.INFO)
 
+
+class MirrorGitRepository(GitRepository): # pylint: disable=R0904
+    """Special Git repository to enable a mirrored clone with a working copy"""
+
+    def set_config(self, name, value, replace=False):
+        """Add a config value"""
+        args = ['--replace-all'] if replace else ['--add']
+        args.extend([name, value])
+        stderr, ret = self._git_inout('config', args)[1:]
+        if ret:
+            raise GitRepositoryError('Failed to set config %s=%s (%s)' %
+                                     (name, value, stderr))
+
+    def force_fetch(self):
+        """Fetch with specific arguments"""
+        # Update all refs
+        self._git_command('fetch', ['-q', '-u', '-p', 'origin'])
+        try:
+            # Fetch remote HEAD separately
+            self._git_command('fetch', ['-q', '-u', 'origin', 'HEAD'])
+        except GitRepositoryError:
+            # If remote HEAD is invalid, invalidate FETCH_HEAD, too
+            with open(os.path.join(self.git_dir, 'FETCH_HEAD'), 'w') as fhead:
+                fhead.write('0000000000000000000000000000000000000000\n')
+
+    @classmethod
+    def clone(cls, path, url, bare=False):
+        """Create a mirrored clone"""
+        if bare:
+            return super(MirrorGitRepository, cls).clone(path, url,
+                                                         mirror=bare,
+                                                         auto_name=False)
+        else:
+            logger.debug('Initializing non-bare mirrored repo')
+            repo = cls.create(path)
+            repo.add_remote_repo('origin', url)
+            repo.set_config('remote.origin.fetch', '+refs/*:refs/*', True)
+            repo.force_fetch()
+            return repo
+
+
 class CachedRepoError(Exception):
     """Repository cache errors"""
     pass
@@ -40,13 +81,17 @@ class CachedRepoError(Exception):
 class CachedRepo(object):
     """Object representing a cached repository"""
 
-    def __init__(self, url):
+    def __init__(self, url, bare=False):
         self.basedir = '/var/cache/obs/git-buildpackage-repos/'
         self.repodir = None
         self.repo = None
         self.lock = None
 
-        # Check repository cache base
+        self._init_cache_base()
+        self._init_git_repo(url, bare)
+
+    def _init_cache_base(self):
+        """Check and initialize repository cache base directory"""
         if 'CACHEDIR' in os.environ:
             self.basedir = os.environ['CACHEDIR']
         logger.debug("Using cache basedir '%s'" % self.basedir)
@@ -58,51 +103,81 @@ class CachedRepo(object):
                 raise CachedRepoError('Failed to create cache base dir: %s' %
                                      str(err))
 
-        # Safe dir name
-        reponame = url.split('/')[-1].split(':')[-1]
-        postfix = hashlib.sha1(url).hexdigest()
-        reponame = reponame + '_' + postfix
-        self.repodir = os.path.join(self.basedir, reponame)
-
-        # Acquire lock
+    def _acquire_lock(self, repodir):
+        """Acquire the repository lock"""
         logger.debug("Acquiring repository lock")
         try:
-            self.lock = open(self.repodir + '.lock', 'w')
+            self.lock = open(repodir + '.lock', 'w')
         except IOError as err:
             raise CachedRepoError('Unable to open repo lock file: %s' % err)
         fcntl.flock(self.lock, fcntl.LOCK_EX)
         logger.debug("Repository lock acquired")
 
-        # Update repo cache
+    def _release_lock(self):
+        """Release the repository lock"""
+        if self.lock:
+            fcntl.flock(self.lock, fcntl.LOCK_UN)
+            self.lock = None
+
+    def _init_git_repo(self, url, bare):
+        """Clone / update a remote git repository"""
+        # Safe repo dir name
+        reponame = url.split('/')[-1].split(':')[-1]
+        postfix = hashlib.sha1(url).hexdigest() # pylint: disable=E1101
+        reponame = reponame + '_' + postfix
+        self.repodir = os.path.join(self.basedir, reponame)
+
+        # Acquire repository lock
+        self._acquire_lock(self.repodir)
+
         if os.path.exists(self.repodir):
             try:
-                self.repo = GitRepository(self.repodir)
+                self.repo = MirrorGitRepository(self.repodir)
             except GitRepositoryError:
                 pass
-            if not self.repo:
+            if not self.repo or self.repo.bare != bare:
                 logger.info('Removing corrupted repo cache %s' % self.repodir)
                 try:
+                    self.repo = None
                     shutil.rmtree(self.repodir)
                 except OSError as err:
                     raise CachedRepoError('Failed to remove repo cache dir: %s'
                                          % str(err))
             else:
                 logger.info('Fetching from remote')
-                self.repo.fetch()
-
+                try:
+                    self.repo.force_fetch()
+                except GitRepositoryError as err:
+                    raise CachedRepoError('Failed to fetch from remote: %s' %
+                                           err)
         if not self.repo:
             logger.info('Cloning from %s' % url)
             try:
-                self.repo = GitRepository.clone(self.repodir, url,
-                                                mirror=True, auto_name=False)
+                self.repo = MirrorGitRepository.clone(self.repodir, url,
+                                                      bare=bare)
             except GitRepositoryError as err:
-                raise CachedRepoError('Failed to clone: %s' % str(err))
-
-    def _release_lock(self):
-        """Release the repository lock"""
-        if self.lock:
-            fcntl.flock(self.lock, fcntl.LOCK_UN)
+                raise CachedRepoError('Failed to clone: %s' % err)
 
     def __del__(self):
         self._release_lock()
 
+    def update_working_copy(self, commitish='HEAD'):
+        """Reset HEAD to the given commit-ish"""
+        if self.repo.bare:
+            raise CachedRepoError('Cannot update working copy of a bare repo')
+
+        # Update HEAD from FETCH_HEAD, so that HEAD points to remote HEAD.
+        # We do it this way because FETCH_HEAD may point to an invalid object
+        # and we don't wont to update the working copy at this point.
+        shutil.copyfile(os.path.join(self.repo.git_dir, 'FETCH_HEAD'),
+                        os.path.join(self.repo.git_dir, 'HEAD'))
+        # Resolve commit-ish to sha-1 and set HEAD (and working copy) to it
+        try:
+            sha = self.repo.rev_parse(commitish)
+            self.repo.set_branch(sha)
+        except GitRepositoryError as err:
+            raise CachedRepoError("Unknown ref '%s': %s" % (commitish, err))
+        self.repo.force_head(sha, hard=True)
+        self.repo.update_submodules(init=True, recursive=True, fetch=True)
+        return sha
+
index 7c5e057dd55ffebc9643250a5a0f40c3e021c5a0..b50772594947a44c34ab91cb058275e81507fb98 100644 (file)
@@ -50,7 +50,8 @@ def parse_args(argv):
     parser = argparse.ArgumentParser()
     parser.add_argument('--url', help='Remote repository URL', required=True)
     parser.add_argument('--outdir', help='Output direcory')
-    parser.add_argument('--revision', help='Remote repository URL')
+    parser.add_argument('--revision', help='Remote repository URL',
+                        default='HEAD')
     parser.add_argument('--verbose', '-v', help='Verbose output',
                         choices=['yes', 'no'])
     parser.add_argument('--spec-vcs-tag', help='Set/update the VCS tag in the'
@@ -70,6 +71,7 @@ def main(argv=None):
     # Create / update cached repository
     try:
         repo = CachedRepo(args.url)
+        args.revision = repo.update_working_copy(args.revision)
     except CachedRepoError as err:
         logger.error('RepoCache: %s' % str(err))
         return 1
@@ -82,6 +84,6 @@ def main(argv=None):
     if ret:
         logger.error('Git-buildpackage-rpm failed, unable to export packaging '
                      'files')
-        return 1
+        return 2
 
     return 0
index 1260d4f0f49f50a7d662928f4aa0548d542f57ba..1d0d04556a8f4813a5ad8d82075815e2fadfc7e9 100644 (file)
@@ -22,11 +22,11 @@ import os
 import shutil
 import stat
 import tempfile
-from nose.tools import assert_raises
+from nose.tools import assert_raises # pylint: disable=E0611
 
 from gbp.git.repository import GitRepository, GitRepositoryError
 
-from obs_service_gbp import CachedRepo, CachedRepoError
+from obs_service_gbp import MirrorGitRepository, CachedRepo, CachedRepoError
 from obs_service_gbp.command import main as service
 
 
@@ -42,6 +42,8 @@ class UnitTestsBase(object):
         orig_repo.commit_dir(TEST_DATA_DIR, 'Initial version', 'master',
                              create_missing_branch=True)
         orig_repo.force_head('master', hard=True)
+        # Make new commit
+        cls.update_repository_file(orig_repo, 'foo.txt', 'new data\n')
         return orig_repo
 
     @classmethod
@@ -58,7 +60,7 @@ class UnitTestsBase(object):
         cls.cachedir = os.path.join(cls.workdir, 'cache')
         os.environ['CACHEDIR'] = cls.cachedir
         # Create an orig repo for testing
-        cls.orig_repo = cls.create_orig_repo('orig').path
+        cls.orig_repo = cls.create_orig_repo('orig')
 
     @classmethod
     def teardown_class(cls):
@@ -67,10 +69,22 @@ class UnitTestsBase(object):
         if not 'DEBUG_NOSETESTS' in os.environ:
             shutil.rmtree(cls.workdir)
 
+    @staticmethod
+    def update_repository_file(repo, filename, data):
+        """Append data to file in git repository and commit changes"""
+        with open(os.path.join(repo.path, filename), 'a') as filep:
+            filep.write(data)
+        repo.add_files(filename)
+        repo.commit_files(filename, "Update %s" % filename)
+
 
 class TestBasicFunctionality(UnitTestsBase):
     """Base class for unit tests"""
 
+    def __init__(self):
+        self.tmpdir = None
+        super(TestBasicFunctionality, self).__init__()
+
     def setup(self):
         """Test case setup"""
         # Change to a temporary directory
@@ -97,31 +111,48 @@ class TestBasicFunctionality(UnitTestsBase):
 
     def test_basic_export(self):
         """Test that export works"""
-        assert service(['--url', self.orig_repo]) == 0
+        assert service(['--url', self.orig_repo.path]) == 0
+
+    def test_gbp_failure(self):
+        """Test git-buildpackage failure"""
+        assert service(['--url', self.orig_repo.path, '--outdir=foo/bar']) == 2
 
     def test_options_outdir(self):
         """Test the --outdir option"""
         outdir = os.path.join(self.tmpdir, 'outdir')
-        assert service(['--url', self.orig_repo, '--outdir=%s' % outdir]) == 0
+        args = ['--url', self.orig_repo.path, '--outdir=%s' % outdir]
+        assert service(args) == 0
         assert os.path.isdir(outdir)
 
     def test_options_revision(self):
         """Test the --revision option"""
-        assert service(['--url', self.orig_repo, '--revision=master']) == 0
-        assert service(['--url', self.orig_repo, '--revision=foobar']) == 1
+        assert service(['--url', self.orig_repo.path, '--revision=master']) == 0
+        assert service(['--url', self.orig_repo.path, '--revision=foobar']) == 1
 
     def test_options_verbose(self):
         """Test the --verbose option"""
-        assert service(['--url', self.orig_repo, '--verbose=yes']) == 0
+        assert service(['--url', self.orig_repo.path, '--verbose=yes']) == 0
         with assert_raises(SystemExit):
-            service(['--url', self.orig_repo, '--verbose=foob'])
+            service(['--url', self.orig_repo.path, '--verbose=foob'])
 
     def test_options_spec_vcs_tag(self):
         """Test the --spec-vcs-tag option"""
-        assert service(['--url', self.orig_repo,
+        assert service(['--url', self.orig_repo.path,
                         '--spec-vcs-tag=orig/%(tagname)s']) == 0
 
 
+class TestObsRepoGitRepository(UnitTestsBase):
+    """Test the special GitRepository class"""
+
+    def test_set_config(self):
+        """Test the set config functionality"""
+        repo = MirrorGitRepository.create('testrepo')
+        with assert_raises(GitRepositoryError):
+            repo.set_config('foo', 'bar')
+        repo.set_config('foo.bar', 'baz')
+        repo.set_config('foo.bar', 'bax', replace=True)
+        assert repo.get_config('foo.bar') == 'bax'
+
 class TestCachedRepo(UnitTestsBase):
     """Test CachedRepo class"""
 
@@ -129,46 +160,117 @@ class TestCachedRepo(UnitTestsBase):
         """Test invalid url"""
         with assert_raises(CachedRepoError):
             CachedRepo('foo/bar.git')
+        with assert_raises(CachedRepoError):
+            CachedRepo('foo/baz.git', bare=True)
+
+        # Try updating from non-existing repo
+        repo = CachedRepo(self.orig_repo.path)
+        del repo
+        shutil.move(self.orig_repo.path, self.orig_repo.path + '.tmp')
+        with assert_raises(CachedRepoError):
+            repo = CachedRepo(self.orig_repo.path)
+        shutil.move(self.orig_repo.path + '.tmp', self.orig_repo.path)
 
     def test_clone_and_fetch(self):
         """Basic test for cloning and fetching"""
         # Clone
-        repo = CachedRepo(self.orig_repo)
+        repo = CachedRepo(self.orig_repo.path)
         assert repo
-        assert repo.repo.bare
-        repo._release_lock()
+        assert repo.repo.bare is not True
+        sha = repo.repo.rev_parse('master')
+        path = repo.repo.path
+        del repo
+        # Make new commit in "upstream"
+        self.update_repository_file(self.orig_repo, 'foo.txt', 'more data\n')
         # Fetch
-        repo2 = CachedRepo(self.orig_repo)
-        assert repo2
-        assert repo.repo.path == repo2.repo.path
+        repo = CachedRepo(self.orig_repo.path)
+        assert repo
+        assert path == repo.repo.path
+        assert sha != repo.repo.rev_parse('master')
+
+    def test_update_working_copy(self):
+        """Test update functionality"""
+        repo = CachedRepo(self.orig_repo.path)
+        # Check that the refs are mapped correctly
+        sha = repo.update_working_copy('HEAD~1')
+        assert sha == self.orig_repo.rev_parse('HEAD~1')
+        sha = self.orig_repo.rev_parse('HEAD')
+        assert sha == repo.update_working_copy('HEAD')
+        assert sha == repo.update_working_copy(sha)
+
+        with assert_raises(CachedRepoError):
+            sha = repo.update_working_copy('foo/bar')
+
+    def test_update_bare(self):
+        """Test update for bare repository"""
+        repo = CachedRepo(self.orig_repo.path, bare=True)
+        with assert_raises(CachedRepoError):
+            repo.update_working_copy('HEAD')
+
+    def test_invalid_remote_head(self):
+        """Test clone/update from remote whose HEAD is invalid"""
+        repo = CachedRepo(self.orig_repo.path)
+        del repo
+
+        # Make remote HEAD point to a non-existent branch
+        orig_branch = self.orig_repo.get_branch()
+        with open(os.path.join(self.orig_repo.git_dir, 'HEAD'), 'w') as head:
+            head.write('ref: refs/heads/non-existent-branch\n')
+
+        repo = CachedRepo(self.orig_repo.path)
+        # Local HEAD should be invalid, now
+        with assert_raises(CachedRepoError):
+            repo.update_working_copy('HEAD')
+        # Test valid refs, too
+        assert repo.update_working_copy('master')
+
+        # Reset orig repo to original state
+        self.orig_repo.set_branch(orig_branch)
 
     def test_corrupted_cache(self):
         """Test recovering from corrupted cache"""
         # Clone
-        repo = CachedRepo(self.orig_repo)
+        repo = CachedRepo(self.orig_repo.path)
         # Corrupt repo
-        shutil.rmtree(os.path.join(repo.repo.path, 'refs'))
+        shutil.rmtree(os.path.join(repo.repo.path, '.git/refs'))
         with assert_raises(GitRepositoryError):
             repo.repo.rev_parse('HEAD')
-        repo._release_lock()
+        del repo
         # Update and check status
-        repo = CachedRepo(self.orig_repo)
+        repo = CachedRepo(self.orig_repo.path)
         assert repo.repo.rev_parse('HEAD')
 
+    def test_changing_repotype(self):
+        """Test changing repo type from bare -> normal"""
+        # Clone
+        repo = CachedRepo(self.orig_repo.path, bare=True)
+        assert repo.repo.bare == True
+        del repo
+        repo = CachedRepo(self.orig_repo.path, bare=False)
+        assert repo.repo.bare == False
+
     def test_cache_access_error(self):
         """Test cached directory with invalid permissions"""
         # Check base cachedir creation access error
         os.chmod(self.workdir, 0)
         with assert_raises(CachedRepoError):
-            repo = CachedRepo(self.orig_repo)
+            repo = CachedRepo(self.orig_repo.path)
         os.chmod(self.workdir, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
-        repo = CachedRepo(self.orig_repo)
-        repo._release_lock()
+        repo = CachedRepo(self.orig_repo.path)
+        del repo
 
-        # Check cache access error
+        # Check cache base dir access error
         os.chmod(self.cachedir, 0)
         with assert_raises(CachedRepoError):
-            repo = CachedRepo(self.orig_repo)
+            repo = CachedRepo(self.orig_repo.path)
+        os.chmod(self.cachedir, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
+        repo = CachedRepo(self.orig_repo.path)
+        del repo
+
+        # Check repodir delete eror
+        os.chmod(self.cachedir, stat.S_IREAD | stat.S_IEXEC)
+        with assert_raises(CachedRepoError):
+            # Change repo type -> tries to delete
+            repo = CachedRepo(self.orig_repo.path, bare=True)
         os.chmod(self.cachedir, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
-        repo = CachedRepo(self.orig_repo)