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

Line 
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>
5#            Brendan Cully <brendan@kublai.com>
6#            Yann Dirson <ydirson@altern.org>
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
16from vcpx.repository import Repository
17from vcpx.shwrap import ExternalCommand, PIPE
18from vcpx.config import ConfigurationError
19from vcpx.source import UpdatableSourceWorkingDir, GetUpstreamChangesetsFailure
20from vcpx.source import ChangesetApplicationFailure
21from vcpx.target import SynchronizableTargetWorkingDir, TargetInitializationFailure
22
23from vcpx import TailorException
24
25class BranchpointFailure(TailorException):
26    "Specified branchpoint not found in parent branch"
27
28    ## generic stuff
29
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')
36        self.PARENT_REPO = project.config.get(self.name, 'parent-repo')
37        self.BRANCHPOINT = project.config.get(self.name, 'branchpoint', 'HEAD')
38        self.BRANCHNAME = project.config.get(self.name, 'branch')
39        if self.BRANCHNAME:
40            self.BRANCHNAME = 'refs/heads/' + self.BRANCHNAME
41
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
64        for each execute().
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)
79
80class GitWorkingDir(UpdatableSourceWorkingDir, SynchronizableTargetWorkingDir):
81
82    def _tryCommand(self, cmd, exception=Exception, pipe=True):
83        c = GitExternalCommand(self.repository,
84                               command = self.repository.command(*cmd), cwd = self.basedir)
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
94    ## UpdatableSourceWorkingDir
95
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):
135        out = self._tryCommand(['merge', '-n', '--no-commit', 'fastforward',
136                                'HEAD', changeset.revision],
137                         ChangesetApplicationFailure)
138
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
148
149    def _changesetForRevision(self, revision):
150        from datetime import datetime
151        from vcpx.changes import Changeset, ChangesetEntry
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 = []
181        cmd = ['diff-tree', '--root', '-r', '-M', '--name-status']
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
189            if len(files) > 0:
190                files.pop(0)
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
202        # Brute-force tag search
203        from os.path import join
204        from os import listdir
205
206        tags = []
207        tagdir = join(self.basedir, '.git', 'refs', 'tags')
208        try:
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)
214        except OSError:
215            # No tag dir
216            pass
217
218        return Changeset(revision, date, user, message, entries, tags=tags)
219
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
225        return self._tryCommand(['rev-parse', '--verify', revision], GetUpstreamChangesetsFailure)[0]
226
227    ## SynchronizableTargetWorkingDir
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:
241            self._tryCommand(['update-index', '--add'] + notdirs)
242
243    def _editPathnames(self, names):
244        """
245        Records a sequence of filesystem objects as updated.
246        """
247
248        # can we assume we don't have directories in the list ?
249        self._tryCommand(['update-index'] + names)
250
251    def __parse_author(self, author):
252        """
253        Parse the author field, returning (name, email)
254        """
255        from email.Utils import parseaddr
256        from vcpx.target import AUTHOR, HOST
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
277        encode = self.repository.encode
278
279        logmessage = []
280        if patchname:
281            logmessage.append(patchname)
282        if changelog:
283            logmessage.append(changelog)
284
285        env = {}
286        env.update(environ)
287
288        treeid = self._tryCommand(['write-tree'])[0]
289
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))
299        (out, err) = c.execute(stdout=PIPE, stderr=PIPE)
300        if c.exit_status:
301            # Do we need to check err to be sure there was no error ?
302            self.log.info("Doing initial commit")
303            parent = False
304        else:
305            # FIXME: I'd prefer to avoid all those "if parent"
306            parent = out.read().split('\n')[0]
307
308        (name, email) = self.__parse_author(author)
309        if name:
310            env['GIT_AUTHOR_NAME'] = encode(name)
311            env['GIT_COMMITTER_NAME'] = encode(name)
312        if email:
313            env['GIT_AUTHOR_EMAIL']=email
314            env['GIT_COMMITTER_EMAIL']=email
315        if date:
316            env['GIT_AUTHOR_DATE']=date.strftime("%Y-%m-%d %H:%M:%S")
317            env['GIT_COMMITTER_DATE']=env['GIT_AUTHOR_DATE']
318        if parent:
319            cmd = self.repository.command('commit-tree', treeid, '-p', parent)
320        else:
321            cmd = self.repository.command('commit-tree', treeid)
322        c = GitExternalCommand(self.repository, cwd=self.basedir, command=cmd)
323
324        logmessage = encode('\n'.join(logmessage))
325        if not logmessage:
326            logmessage = 'No commit message\n'
327        if not logmessage.endswith('\n'):
328            logmessage += '\n'
329        (out, _) = c.execute(stdout=PIPE, env=env, input=logmessage)
330        if c.exit_status:
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:
337                raise ChangesetApplicationFailure("%s returned status %d" %
338                                                  (str(c), c.exit_status))
339        else:
340            commitid=out.read().split('\n')[0]
341
342            if parent:
343                self._tryCommand(['update-ref', refname, commitid, parent])
344            else:
345                self._tryCommand(['update-ref', refname, commitid])
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)
350        c = GitExternalCommand(self.repository, cwd=self.basedir, command=cmd)
351        c.execute()
352
353        if c.exit_status:
354            raise ChangesetApplicationFailure("%s returned status %d" %
355                                              (str(c), c.exit_status))
356
357    def _removePathnames(self, names):
358        """
359        Remove some filesystem object.
360        """
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)
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
380        from vcpx.dualwd import IGNORED_METADIRS
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        """
403        Initialize .git through ``git init-db`` or ``git-clone``.
404        """
405
406        from os import renames, mkdir
407        from os.path import join, exists
408
409        if not exists(join(self.basedir, self.repository.METADIR)):
410            if self.repository.PARENT_REPO:
411                cmd = self.repository.command("clone", "--shared", "-n",
412                                              self.repository.PARENT_REPO, 'tmp')
413                clone = GitExternalCommand(self.repository, cwd=self.basedir, command=cmd)
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)
423                reset = GitExternalCommand(self.repository, cwd=self.basedir, command=cmd)
424                reset.execute()
425                if reset.exit_status:
426                    raise TargetInitializationFailure(
427                        "%s returned status %s" % (str(reset), reset.exit_status))
428
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
435                bp = self._tryCommand(['rev-parse', self.repository.BRANCHPOINT],
436                                      BranchpointFailure)[0]
437                self._tryCommand(['read-tree', bp])
438                self._tryCommand(['update-ref', self.repository.BRANCHNAME, bp])
439                #self._tryCommand(['checkout-index'])
440
441            else:
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
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))
452
453    def _prepareWorkingDirectory(self, source_repo):
454        """
455        Create the .git/info/exclude.
456        """
457
458        from os.path import join, exists
459        from os import mkdir
460        from vcpx.dualwd import IGNORED_METADIRS
461
462        # create info/excludes in storagedir
463        infodir = join(self.basedir, self.repository.storagedir, 'info')
464        if not exists(infodir):
465            mkdir(infodir)
466
467        # Create the .git/info/exclude file, that contains an
468        # fnmatch per line with metadirs to be skipped.
469        ignore = open(join(infodir, 'exclude'), 'a')
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)
480            ignore.write('\n')
481            ignore.write(sfrelname+'.old')
482            ignore.write('\n')
483            ignore.write(sfrelname+'.journal')
484            ignore.write('\n')
485        ignore.close()
486
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,
491            initial or self.repository.PARENT_REPO)
Note: See TracBrowser for help on using the repository browser.