source: tailor/vcpx/repository/git.py @ 1199

Revision 1199, 18.3 KB checked in by ydirson@…, 7 years ago (diff)

[git] Improve error cases when importing branches

This patch creates a new exception to identify the case where the user made an error
when specifying a "branchpoint" parameter, and does an explicit check to abort when
attempting to initialize an already-intialized git target repo (most notably hit in

RevLine 
[783]1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: vcpx -- Git target (using git-core)
3# :Creato:   Thu  1 Sep 2005 04:01:37 EDT
4# :Autore:   Todd Mokros <tmokros@tmokros.net>
[1012]5#            Brendan Cully <brendan@kublai.com>
[1195]6#            Yann Dirson <ydirson@altern.org>
[783]7# :Licenza:  GNU General Public License
8#
9
10"""
11This module implements the backend for Git using git-core.
12"""
13
14__docformat__ = 'reStructuredText'
15
[1179]16from vcpx.repository import Repository
17from vcpx.shwrap import ExternalCommand, PIPE
[1195]18from vcpx.config import ConfigurationError
[1179]19from vcpx.source import UpdatableSourceWorkingDir, GetUpstreamChangesetsFailure
20from vcpx.source import ChangesetApplicationFailure
21from vcpx.target import SynchronizableTargetWorkingDir, TargetInitializationFailure
22
[1199]23from vcpx import TailorException
24
25class BranchpointFailure(TailorException):
26    "Specified branchpoint not found in parent branch"
[1179]27
[1194]28    ## generic stuff
29
[1179]30class GitRepository(Repository):
31    METADIR = '.git'
32
33    def _load(self, project):
34        Repository._load(self, project)
35        self.EXECUTABLE = project.config.get(self.name, 'git-command', 'git')
[1189]36        self.PARENT_REPO = project.config.get(self.name, 'parent-repo')
[1184]37        self.BRANCHPOINT = project.config.get(self.name, 'branchpoint', 'HEAD')
[1195]38        self.BRANCHNAME = project.config.get(self.name, 'branch')
39        if self.BRANCHNAME:
40            self.BRANCHNAME = 'refs/heads/' + self.BRANCHNAME
[1179]41
[1195]42        if self.repository and self.PARENT_REPO:
43            self.log.critical('Cannot make sense of both "repository" and "parent-repo" parameters')
44            raise ConfigurationError ('Must specify only one of "repository" and "parent-repo"')
45
46        if self.BRANCHNAME and not self.repository:
47            self.log.critical('Cannot make sense of "branch" if "repository" is not set')
48            raise ConfigurationError ('Missing "repository" to make use o "branch"')
49
50        self.env = {}
51
52        if self.repository:
53            self.storagedir = self.repository
54            self.env['GIT_DIR'] = self.storagedir
55            self.env['GIT_INDEX_FILE'] = self.METADIR + '/index'
56        else:
57            self.storagedir = self.METADIR
58
59class GitExternalCommand(ExternalCommand):
60    def __init__(self, repo, command=None, cwd=None):
61        """
62        Initialize an ExternalCommand instance tied to a GitRepository
63        from which it inherits a set of environment variables to use
[1198]64        for each execute().
[1195]65        """
66
67        self.repo = repo
68        return ExternalCommand.__init__(self, command, cwd)
69
70    def execute(self, *args, **kwargs):
71        """Execute the command, with controlled environment."""
72
73        if not kwargs.has_key('env'):
74            kwargs['env'] = {}
75
76        kwargs['env'].update(self.repo.env)
77
78        return ExternalCommand.execute(self, *args, **kwargs)
[1012]79
[1113]80class GitWorkingDir(UpdatableSourceWorkingDir, SynchronizableTargetWorkingDir):
[1194]81
82    def _tryCommand(self, cmd, exception=Exception, pipe=True):
[1195]83        c = GitExternalCommand(self.repository,
84                               command = self.repository.command(*cmd), cwd = self.basedir)
[1194]85        if pipe:
86            output = c.execute(stdout=PIPE)[0]
87        else:
88            c.execute()
89        if c.exit_status:
90            raise exception(str(c) + ' failed')
91        if pipe:
92            return output.read().split('\n')
93
[1012]94    ## UpdatableSourceWorkingDir
[1194]95
[1012]96    def _checkoutUpstreamRevision(self, revision):
97        """ git clone """
98        from os import rename, rmdir
99        from os.path import join
100
101        # Right now we clone the entire repository and just check out to the
102        # current rev because it makes revision parsing easier. We can't
103        # easily check out arbitrary revisions anyway, but we could probably
104        # handle HEAD (master) as a special case...
105        # git clone won't checkout into an existing directory
106        target = join(self.basedir, '.gittmp')
107        # might want -s if we can determine that the path is local. Then again,
108        # that makes it a little unsafe to do git write actions here
109        self._tryCommand(['clone', '-n', self.repository.repository, target],
110                         ChangesetApplicationFailure, False)
111
112        rename(join(target, '.git'), join(self.basedir, '.git'))
113        rmdir(target)
114
115        rev = self._getRev(revision)
116        if rev != revision:
117            self.log.info('Checking out revision %s (%s)' % (rev, revision))
118        else:
119            self.log.info('Checking out revision ' + rev)
120        self._tryCommand(['reset', '--hard', rev], ChangesetApplicationFailure, False)
121
122        return self._changesetForRevision(rev)
123
124    def _getUpstreamChangesets(self, since):
125        self._tryCommand(['fetch'], GetUpstreamChangesetsFailure, False)
126
127        revs = self._tryCommand(['rev-list', '^' + since, 'origin'],
128                                GetUpstreamChangesetsFailure)[:-1]
129        revs.reverse()
130        for rev in revs:
131            self.log.info('Updating to revision ' + rev)
132            yield self._changesetForRevision(rev)
133
134    def _applyChangeset(self, changeset):
[1141]135        out = self._tryCommand(['merge', '-n', '--no-commit', 'fastforward',
136                                'HEAD', changeset.revision],
137                         ChangesetApplicationFailure)
[1012]138
[1141]139        conflicts = []
140        for line in out:
141            if line.endswith(': needs update'):
142                conflicts.append(line[:-14])
143
144        if conflicts:
145            self.log.warning("Conflict after 'git merge': %s", ' '.join(conflicts))
146
147        return conflicts
[1012]148
149    def _changesetForRevision(self, revision):
150        from datetime import datetime
[1179]151        from vcpx.changes import Changeset, ChangesetEntry
[1012]152
153        action_map = {'A': ChangesetEntry.ADDED, 'D': ChangesetEntry.DELETED,
154                      'M': ChangesetEntry.UPDATED, 'R': ChangesetEntry.RENAMED}
155
156        # find parent
157        lines = self._tryCommand(['rev-list', '--pretty=raw', '--max-count=1', revision],
158                                 GetUpstreamChangesetsFailure)
159        parents = []
160        user = Changeset.ANONYMOUS_USER
161        loglines = []
162        date = None
163        for line in lines:
164            if line.startswith('parent'):
165                parents.append(line.split(' ').pop())
166            if line.startswith('author'):
167                author_fields = line.split(' ')[1:]
168                tz = int(author_fields.pop())
169                dt = int(author_fields.pop())
170                user = ' '.join(author_fields)
171                tzsecs = abs(tz)
172                tzsecs = (tz / 100 * 60 + tz % 100) * 60
173                if tz < 0:
174                    tzsecs = -tzsecs
175                date = datetime.utcfromtimestamp(dt + tzsecs)
176            if line.startswith('    '):
177                loglines.append(line.lstrip('    '))
178
179        message = '\n'.join(loglines)
180        entries = []
[1138]181        cmd = ['diff-tree', '--root', '-r', '-M', '--name-status']
[1012]182        # haven't thought about merges yet...
183        if parents:
184            cmd.append(parents[0])
185        cmd.append(revision)
186        files = self._tryCommand(cmd, GetUpstreamChangesetsFailure)[:-1]
187        if not parents:
188            # git lets us know what it's diffing against if we omit parent
[1034]189            if len(files) > 0:
190                files.pop(0)
[1012]191        for line in files:
192            fields = line.split('\t')
193            state = fields.pop(0)
194            name = fields.pop()
195            e = ChangesetEntry(name)
196            e.action_kind = action_map[state[0]]
197            if e.action_kind == ChangesetEntry.RENAMED:
198                e.old_name = fields.pop()
199
200            entries.append(e)
201
[1163]202        # Brute-force tag search
203        from os.path import join
204        from os import listdir
[1035]205
[1163]206        tags = []
207        tagdir = join(self.basedir, '.git', 'refs', 'tags')
208        try:
[1036]209            for tag in listdir(tagdir):
210                # Consider caching stat info per tailor run
211                tagrev = self._tryCommand(['rev-list', '--max-count=1', tag])[0]
212                if (tagrev == revision):
213                    tags.append(tag)
[1163]214        except OSError:
215            # No tag dir
216            pass
[1035]217
218        return Changeset(revision, date, user, message, entries, tags=tags)
[1013]219
[1012]220    def _getRev(self, revision):
221        """ Return the git object corresponding to the symbolic revision """
222        if revision == 'INITIAL':
223            return self._tryCommand(['rev-list', 'HEAD'], GetUpstreamChangesetsFailure)[-2]
224
[1175]225        return self._tryCommand(['rev-parse', '--verify', revision], GetUpstreamChangesetsFailure)[0]
[783]226
[1113]227    ## SynchronizableTargetWorkingDir
[783]228
229    def _addPathnames(self, names):
230        """
231        Add some new filesystem objects.
232        """
233
234        from os.path import join, isdir
235
236        # Currently git does not handle directories at all, so filter
237        # them out.
238
239        notdirs = [n for n in names if not isdir(join(self.basedir, n))]
240        if notdirs:
[1176]241            self._tryCommand(['update-index', '--add'] + notdirs)
242
[1191]243    def _editPathnames(self, names):
[1176]244        """
245        Records a sequence of filesystem objects as updated.
246        """
247
[1182]248        # can we assume we don't have directories in the list ?
249        self._tryCommand(['update-index'] + names)
[783]250
251    def __parse_author(self, author):
252        """
253        Parse the author field, returning (name, email)
254        """
255        from email.Utils import parseaddr
[1179]256        from vcpx.target import AUTHOR, HOST
[783]257
258        if author.find('@') > -1:
259            name, email = parseaddr(author)
260        else:
261            name, email = author, ''
262        name = name.strip()
263        email = email.strip()
264        if not name:
265            name = AUTHOR
266        if not email:
267            email = "%s@%s" % (AUTHOR, HOST)
268        return (name, email)
269
270    def _commit(self, date, author, patchname, changelog=None, entries=None):
271        """
272        Commit the changeset.
273        """
274
275        from os import environ
276
[1016]277        encode = self.repository.encode
278
[783]279        logmessage = []
280        if patchname:
[850]281            logmessage.append(patchname)
[783]282        if changelog:
[850]283            logmessage.append(changelog)
[783]284
285        env = {}
286        env.update(environ)
287
[1181]288        treeid = self._tryCommand(['write-tree'])[0]
289
[1195]290        # in single-repository mode, only update the relevant branch
291        if self.repository.BRANCHNAME:
292            refname = self.repository.BRANCHNAME
293        else:
294            refname = 'HEAD'
295
296        # find the previous commit on the branch if any
297        c = GitExternalCommand(self.repository, cwd=self.basedir,
298                               command=self.repository.command('rev-parse', refname))
[1181]299        (out, err) = c.execute(stdout=PIPE, stderr=PIPE)
300        if c.exit_status:
[1182]301            # Do we need to check err to be sure there was no error ?
302            self.log.info("Doing initial commit")
303            parent = False
[1181]304        else:
[1182]305            # FIXME: I'd prefer to avoid all those "if parent"
306            parent = out.read().split('\n')[0]
[1181]307
[783]308        (name, email) = self.__parse_author(author)
309        if name:
[1016]310            env['GIT_AUTHOR_NAME'] = encode(name)
311            env['GIT_COMMITTER_NAME'] = encode(name)
[783]312        if email:
313            env['GIT_AUTHOR_EMAIL']=email
[995]314            env['GIT_COMMITTER_EMAIL']=email
[783]315        if date:
[995]316            env['GIT_AUTHOR_DATE']=date.strftime("%Y-%m-%d %H:%M:%S")
317            env['GIT_COMMITTER_DATE']=env['GIT_AUTHOR_DATE']
[1182]318        if parent:
[1181]319            cmd = self.repository.command('commit-tree', treeid, '-p', parent)
[1182]320        else:
[1181]321            cmd = self.repository.command('commit-tree', treeid)
[1195]322        c = GitExternalCommand(self.repository, cwd=self.basedir, command=cmd)
[783]323
[1016]324        logmessage = encode('\n'.join(logmessage))
[1122]325        if not logmessage:
326            logmessage = 'No commit message\n'
[995]327        if not logmessage.endswith('\n'):
328            logmessage += '\n'
329        (out, _) = c.execute(stdout=PIPE, env=env, input=logmessage)
[783]330        if c.exit_status:
[1163]331            failed = True
332            if out:
333                for line in [x.strip() for x in out if x[0] != '#']:
334                    if line == 'nothing to commit':
335                        failed = False
336            if failed:
[786]337                raise ChangesetApplicationFailure("%s returned status %d" %
338                                                  (str(c), c.exit_status))
[1182]339        else:
340            commitid=out.read().split('\n')[0]
[1195]341
[1182]342            if parent:
[1195]343                self._tryCommand(['update-ref', refname, commitid, parent])
[1181]344            else:
[1195]345                self._tryCommand(['update-ref', refname, commitid])
[1003]346
347    def _tag(self, tag):
348        # Allow a new tag to overwrite an older one with -f
349        cmd = self.repository.command("tag", "-f", tag)
[1195]350        c = GitExternalCommand(self.repository, cwd=self.basedir, command=cmd)
[1003]351        c.execute()
352
353        if c.exit_status:
354            raise ChangesetApplicationFailure("%s returned status %d" %
355                                              (str(c), c.exit_status))
[783]356
357    def _removePathnames(self, names):
358        """
359        Remove some filesystem object.
360        """
[1176]361
362        from os.path import join, isdir
363
364        # Currently git does not handle directories at all, so filter
365        # them out.
366
367        notdirs = [n for n in names if not isdir(join(self.basedir, n))]
368        if notdirs:
369            self._tryCommand(['update-index', '--remove'] + notdirs)
[783]370
371    def _renamePathname(self, oldname, newname):
372        """
373        Rename a filesystem object.
374        """
375        # In the future, we may want to switch to using
376        # git rename, in case renames ever get more support
377        # in git.  It currently just does and add and remove.
378        from os.path import join, isdir
379        from os import walk
[1179]380        from vcpx.dualwd import IGNORED_METADIRS
[783]381
382        if isdir(join(self.basedir, newname)):
383            # Given lack of support for directories in current Git,
384            # loop over all files under the new directory and
385            # do a add/remove on them.
386            skip = len(self.basedir)+len(newname)+2
387            for dir, subdirs, files in walk(join(self.basedir, newname)):
388                prefix = dir[skip:]
389
390                for excd in IGNORED_METADIRS:
391                    if excd in subdirs:
392                        subdirs.remove(excd)
393
394                for f in files:
395                    self._removePathnames([join(oldname, prefix, f)])
396                    self._addPathnames([join(newname, prefix, f)])
397        else:
398            self._removePathnames([oldname])
399            self._addPathnames([newname])
400
401    def _prepareTargetRepository(self):
402        """
[1184]403        Initialize .git through ``git init-db`` or ``git-clone``.
[783]404        """
405
[1195]406        from os import renames, mkdir
[783]407        from os.path import join, exists
408
409        if not exists(join(self.basedir, self.repository.METADIR)):
[1195]410            if self.repository.PARENT_REPO:
[1184]411                cmd = self.repository.command("clone", "--shared", "-n",
412                                              self.repository.PARENT_REPO, 'tmp')
[1195]413                clone = GitExternalCommand(self.repository, cwd=self.basedir, command=cmd)
[1184]414                clone.execute()
415                if clone.exit_status:
416                    raise TargetInitializationFailure(
417                        "%s returned status %s" % (str(clone), clone.exit_status))
418
419                renames('%s/%s/tmp/.git' % (self.repository.rootdir, self.repository.subdir),
420                        '%s/%s/.git' % (self.repository.rootdir, self.repository.subdir))
421               
422                cmd = self.repository.command("reset", "--soft", self.repository.BRANCHPOINT)
[1195]423                reset = GitExternalCommand(self.repository, cwd=self.basedir, command=cmd)
[1184]424                reset.execute()
425                if reset.exit_status:
426                    raise TargetInitializationFailure(
427                        "%s returned status %s" % (str(reset), reset.exit_status))
[783]428
[1195]429            elif self.repository.repository and self.repository.BRANCHNAME:
430                # ...and exists(self.repository.storagedir) ?
431
432                # initialization of a new branch in single-repository mode
433                mkdir(join(self.basedir, self.repository.METADIR))
434
[1199]435                bp = self._tryCommand(['rev-parse', self.repository.BRANCHPOINT],
436                                      BranchpointFailure)[0]
[1195]437                self._tryCommand(['read-tree', bp])
438                self._tryCommand(['update-ref', self.repository.BRANCHNAME, bp])
439                #self._tryCommand(['checkout-index'])
440
441            else:
[1199]442                if exists(join(self.basedir, self.repository.storagedir)):
443                    raise TargetInitializationFailure(
444                        "Repository %s already exists - "
445                        "did you forget to set \"branch\" parameter ?" % self.repository.storagedir)
446
[1195]447                self._tryCommand(['init-db'])
448                if self.repository.repository:
449                    # in this mode, the db is not stored in working dir, so we
450                    # have to create .git ourselves
451                    mkdir(join(self.basedir, self.repository.METADIR))
[783]452
453    def _prepareWorkingDirectory(self, source_repo):
454        """
455        Create the .git/info/exclude.
456        """
457
[998]458        from os.path import join, exists
459        from os import mkdir
[1179]460        from vcpx.dualwd import IGNORED_METADIRS
[783]461
[1195]462        # create info/excludes in storagedir
463        infodir = join(self.basedir, self.repository.storagedir, 'info')
[998]464        if not exists(infodir):
465            mkdir(infodir)
466
[783]467        # Create the .git/info/exclude file, that contains an
468        # fnmatch per line with metadirs to be skipped.
[998]469        ignore = open(join(infodir, 'exclude'), 'a')
[783]470        ignore.write('\n')
471        ignore.write('\n'.join(['%s' % md
472                                for md in IGNORED_METADIRS]))
473        ignore.write('\n')
474        if self.logfile.startswith(self.basedir):
475            ignore.write(self.logfile[len(self.basedir)+1:])
476            ignore.write('\n')
477        if self.state_file.filename.startswith(self.basedir):
478            sfrelname = self.state_file.filename[len(self.basedir)+1:]
479            ignore.write(sfrelname)
[992]480            ignore.write('\n')
481            ignore.write(sfrelname+'.old')
[783]482            ignore.write('\n')
483            ignore.write(sfrelname+'.journal')
484            ignore.write('\n')
485        ignore.close()
486
[1188]487    def importFirstRevision(self, source_repo, changeset, initial):
488        # If we have a parent repository, always track from INITIAL
489        SynchronizableTargetWorkingDir.importFirstRevision(
490            self, source_repo, changeset,
[1189]491            initial or self.repository.PARENT_REPO)
Note: See TracBrowser for help on using the repository browser.