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

Revision 1198, 17.9 KB checked in by lele@…, 7 years ago (diff)

Fixed typo in a comment

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