source: tailor/vcpx/target.py @ 893

Revision 893, 15.2 KB checked in by lele@…, 8 years ago (diff)

Handle the "replace" operation, that is a remove+rename
In this case, the remove must be done before the rename on the target
backend as well.

Line 
1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: vcpx -- Syncable targets
3# :Creato:   ven 04 giu 2004 00:27:07 CEST
4# :Autore:   Lele Gaifax <lele@nautilus.homeip.net>
5# :Licenza:  GNU General Public License
6#
7
8"""
9Syncronizable targets are the simplest abstract wrappers around a
10working directory under two different version control systems.
11"""
12
13__docformat__ = 'reStructuredText'
14
15import socket
16from workdir import WorkingDir
17
18HOST = socket.getfqdn()
19AUTHOR = "tailor"
20BOOTSTRAP_PATCHNAME = 'Tailorization'
21BOOTSTRAP_CHANGELOG = """\
22Import of the upstream sources from
23%(source_repository)s
24   Revision: %(revision)s
25"""
26
27class TargetInitializationFailure(Exception):
28    "Failure initializing the target VCS"
29
30class ChangesetReplayFailure(Exception):
31    "Failure replaying the changeset on the target system"
32
33class SyncronizableTargetWorkingDir(WorkingDir):
34    """
35    This is an abstract working dir usable as a *shadow* of another
36    kind of VC, sharing the same working directory.
37
38    Most interesting entry points are:
39
40    replayChangeset
41        to replay an already applied changeset, to mimic the actions
42        performed by the upstream VC system on the tree such as
43        renames, deletions and adds.  This is an useful argument to
44        feed as ``replay`` to ``applyUpstreamChangesets``
45
46    importFirstRevision
47        to initialize a pristine working directory tree under this VC
48        system, possibly extracted under a different kind of VC
49
50    Subclasses MUST override at least the _underscoredMethods.
51    """
52
53    PATCH_NAME_FORMAT = '[%(project)s @ %(revision)s]'
54    """
55    The format string used to compute the patch name, used by underlying VCS.
56    """
57
58    REMOVE_FIRST_LOG_LINE = False
59    """
60    When true, remove the first line from the upstream changelog.
61    """
62
63    def __getPatchNameAndLog(self, changeset):
64        """
65        Return a tuple (patchname, changelog) interpolating changeset's
66        information with the template above.
67        """
68
69        if changeset.log == '':
70            firstlogline = 'Empty log message'
71            remaininglog = ''
72        else:
73            loglines = changeset.log.split('\n')
74            if len(loglines)>1:
75                firstlogline = loglines[0]
76                remaininglog = '\n'.join(loglines[1:])
77            else:
78                firstlogline = changeset.log
79                remaininglog = ''
80
81        patchname = self.PATCH_NAME_FORMAT % {
82            'project': self.repository.project.name,
83            'revision': changeset.revision,
84            'author': changeset.author,
85            'date': changeset.date,
86            'firstlogline': firstlogline,
87            'remaininglog': remaininglog}
88        if self.REMOVE_FIRST_LOG_LINE:
89            changelog = remaininglog
90        else:
91            changelog = changeset.log
92        return patchname, changelog
93
94    def replayChangeset(self, changeset):
95        """
96        Do whatever is needed to replay the changes under the target
97        VC, to register the already applied (under the other VC)
98        changeset.
99        """
100
101        changeset = self._adaptChangeset(changeset)
102        if changeset is None:
103            return
104
105        try:
106            self._replayChangeset(changeset)
107        except:
108            self.log_error(str(changeset), exc=True)
109            raise
110        patchname, log = self.__getPatchNameAndLog(changeset)
111        entries = self._getCommitEntries(changeset)
112        self._commit(changeset.date, changeset.author, patchname, log, entries)
113        for tag in changeset.tags:
114            self._tag(tag)
115        self._dismissChangeset(changeset)
116
117    def __getPrefixToSource(self):
118        """
119        Compute and return the "offset" between source and target basedirs,
120        or None when not using shared directories, or there's no offset.
121        """
122
123        ssubdir = self.repository.project.source.subdir
124        tsubdir = self.repository.project.target.subdir
125        if self.shared_basedirs and ssubdir <> tsubdir:
126            if tsubdir == '.':
127                prefix = ssubdir
128            else:
129                if not tsubdir.endswith('/'):
130                    tsubdir += '/'
131                prefix = ssubdir[len(tsubdir):]
132            return prefix
133        else:
134            return None
135
136    def _normalizeEntryPaths(self, entry):
137        """
138        Normalize the name and old_name of an entry.
139
140        The ``name`` and ``old_name`` of an entry are pathnames coming
141        from the upstream system, and is usually (although there is no
142        guarantee it actually is) a UNIX style path with forward
143        slashes "/" as separators.
144
145        This implementation uses normpath to adapt the path to the
146        actual OS convention, but subclasses may eventually override
147        this to use their own canonicalization of ``name`` and
148        ``old_name``.
149        """
150
151        from os.path import normpath
152
153        entry.name = normpath(entry.name)
154        if entry.old_name:
155            entry.old_name = normpath(entry.old_name)
156
157    def __adaptEntriesPath(self, changeset):
158        """
159        If the source basedir is a subdirectory of the target, adjust
160        all the pathnames adding the prefix computed by difference.
161        """
162
163        from copy import deepcopy
164        from os.path import join
165
166        if not changeset.entries:
167            return changeset
168
169        prefix = self.__getPrefixToSource()
170        adapted = deepcopy(changeset)
171        for e in adapted.entries:
172            if prefix:
173                e.name = join(prefix, e.name)
174                if e.old_name:
175                    e.old_name = join(prefix, e.old_name)
176            self._normalizeEntryPaths(e)
177        return adapted
178
179    def _adaptEntries(self, changeset):
180        """
181        Do whatever is needed to adapt entries to the target system.
182
183        This implementation adds a prefix to each path if needed, when
184        the target basedir *contains* the source basedir. Also, each
185        path is normalized thru ``normpath()`` or whatever equivalent
186        operation provided by the specific target. It operates on and
187        returns a copy of the given changeset.
188
189        Subclasses shall eventually extend this to exclude unwanted
190        entries, eventually returning None when all entries were
191        excluded, to avoid the commit on target of an empty changeset.
192        """
193
194        adapted = self.__adaptEntriesPath(changeset)
195        return adapted
196
197    def _adaptChangeset(self, changeset):
198        """
199        Do whatever needed before replay and return the adapted changeset.
200
201        This implementation calls ``self._adaptEntries()``, then
202        executes the adapters defined by before-commit on the project:
203        each adapter is run in turn, and may return False to indicate
204        that the changeset shouldn't be replayed at all. They are
205        otherwise free to alter the changeset in any meaningful way.
206        """
207
208        from copy import copy
209
210        adapted = self._adaptEntries(changeset)
211        if adapted:
212            if self.repository.project.before_commit:
213                adapted = copy(adapted)
214
215                for adapter in self.repository.project.before_commit:
216                    if not adapter(self, adapted):
217                        return None
218        return adapted
219
220    def _dismissChangeset(self, changeset):
221        """
222        Do whatever needed after commit.
223
224        This execute the adapters defined by after-commit on the project,
225        for example tagging in some way the target repository upon some
226        particular kind of changeset.
227        """
228
229        if self.repository.project.after_commit:
230            for farewell in self.repository.project.after_commit:
231                farewell(self, changeset)
232
233    def _getCommitEntries(self, changeset):
234        """
235        Extract the names of the entries for the commit phase.
236        """
237
238        return [e.name for e in changeset.entries]
239
240    def _replayChangeset(self, changeset):
241        """
242        Replicate the actions performed by the changeset on the tree of
243        files.
244        """
245
246        from os.path import join, isdir
247
248        added = changeset.addedEntries()
249        renamed = changeset.renamedEntries()
250        removed = changeset.removedEntries()
251
252        # Sort added entries, to be sure that /root/addedDir/ comes
253        # before /root/addedDir/addedSubdir
254        added.sort(lambda x,y: cmp(x.name, y.name))
255
256        # Sort removes in reverse order, to delete directories after
257        # their contents.
258        removed.sort(lambda x,y: cmp(y.name, x.name))
259
260        # Replay the actions
261
262        if renamed and removed:
263            # Handle the "replace" operation, that is a remove+rename
264
265            renames = [e.name for e in renamed]
266            removesfirst = []
267            for rem in removed:
268                if rem in renames:
269                    removesfirst.append(rem)
270
271            if removedfirst:
272                self._removeEntries(removedfirst)
273                for rem in removesfirst:
274                    removed.remove(rem)
275
276        if renamed: self._renameEntries(renamed)
277        if removed: self._removeEntries(removed)
278        if added: self._addEntries(added)
279
280        # Finally, deal with "copied" directories. The simple way is
281        # executing an _addSubtree on each of them, evenif this may
282        # cause "warnings" on items just moved/added above...
283
284        while added:
285            subdir = added.pop(0).name
286            if isdir(join(self.basedir, subdir)):
287                self._addSubtree(subdir)
288                added = [e for e in added if not e.name.startswith(subdir)]
289
290    def _addEntries(self, entries):
291        """
292        Add a sequence of entries
293        """
294
295        self._addPathnames([e.name for e in entries])
296
297    def _addPathnames(self, names):
298        """
299        Add some new filesystem objects.
300        """
301
302        raise "%s should override this method" % self.__class__
303
304    def _addSubtree(self, subdir):
305        """
306        Add a whole subtree.
307
308        This implementation crawl down the whole subtree, adding
309        entries (subdirs, skipping the usual VC-specific control
310        directories such as ``.svn``, ``_darcs`` or ``CVS``, and
311        files).
312
313        Subclasses may use a better way, if the backend implements
314        a recursive add that skips the various metadata directories.
315        """
316
317        from os.path import join
318        from os import walk
319        from dualwd import IGNORED_METADIRS
320
321        exclude = []
322
323        if self.state_file.filename.startswith(self.basedir):
324            sfrelname = self.state_file.filename[len(self.basedir)+1:]
325            exclude.append(sfrelname)
326            exclude.append(sfrelname+'.journal')
327
328        if self.logfile.startswith(self.basedir):
329            exclude.append(self.logfile[len(self.basedir)+1:])
330
331        if subdir and subdir<>'.':
332            self._addPathnames([subdir])
333
334        for dir, subdirs, files in walk(join(self.basedir, subdir)):
335            for excd in IGNORED_METADIRS:
336                if excd in subdirs:
337                    subdirs.remove(excd)
338
339            for excf in exclude:
340                if excf in files:
341                    files.remove(excf)
342
343            if subdirs or files:
344                self._addPathnames([join(dir, df)[len(self.basedir)+1:]
345                                    for df in subdirs + files])
346
347    def _commit(self, date, author, patchname, changelog=None, entries=None):
348        """
349        Commit the changeset.
350        """
351
352        raise "%s should override this method" % self.__class__
353
354    def _removeEntries(self, entries):
355        """
356        Remove a sequence of entries.
357        """
358
359        self._removePathnames([e.name for e in entries])
360
361    def _removePathnames(self, names):
362        """
363        Remove some filesystem object.
364        """
365
366        raise "%s should override this method" % self.__class__
367
368    def _renameEntries(self, entries):
369        """
370        Rename a sequence of entries, adding all the parent directories
371        of each entry.
372        """
373
374        from os.path import split
375
376        added = []
377        for e in entries:
378            parents = []
379            parent = split(e.name)[0]
380            while parent:
381                if not parent in added:
382                    parents.append(parent)
383                    added.append(parent)
384                parent = split(parent)[0]
385            if parents:
386                parents.reverse()
387                self._addPathnames(parents)
388
389            self._renamePathname(e.old_name, e.name)
390
391    def _renamePathname(self, oldname, newname):
392        """
393        Rename a filesystem object to some other name/location.
394        """
395
396        raise "%s should override this method" % self.__class__
397
398    def prepareWorkingDirectory(self, source_repo):
399        """
400        Do anything required to setup the hosting working directory.
401        """
402
403        self._prepareWorkingDirectory(source_repo)
404
405    def _prepareWorkingDirectory(self, source_repo):
406        """
407        Possibly checkout a working copy of the target VC, that will host the
408        upstream source tree, when overriden by subclasses.
409        """
410
411    def prepareTargetRepository(self):
412        """
413        Do anything required to host the target repository.
414        """
415
416        from os import makedirs
417        from os.path import join, exists
418
419        if not exists(self.basedir):
420            makedirs(self.basedir)
421
422        self._prepareTargetRepository()
423
424        prefix = self.__getPrefixToSource()
425        if prefix:
426            if not exists(join(self.basedir, prefix)):
427                # At bootstrap time, we assume that if the user
428                # extracted the source manually, she added
429                # the subdir, before doing that.
430                makedirs(join(self.basedir, prefix))
431                self._addPathnames([prefix])
432
433    def _prepareTargetRepository(self):
434        """
435        Possibly create or connect to the repository, when overriden
436        by subclasses.
437        """
438
439    def importFirstRevision(self, source_repo, changeset, initial):
440        """
441        Initialize a new working directory, just extracted from
442        some other VC system, importing everything's there.
443        """
444
445        self._initializeWorkingDir()
446        # Execute the precommit hooks, but ignore None results
447        changeset = self._adaptChangeset(changeset) or changeset
448        revision = changeset.revision
449        source_repository = str(source_repo)
450        if initial:
451            author = changeset.author
452            patchname, log = self.__getPatchNameAndLog(changeset)
453        else:
454            author = "%s@%s" % (AUTHOR, HOST)
455            patchname = BOOTSTRAP_PATCHNAME
456            log = BOOTSTRAP_CHANGELOG % locals()
457        self._commit(changeset.date, author, patchname, log)
458        for tag in changeset.tags:
459            self._tag(tag)
460        self._dismissChangeset(changeset)
461
462    def _initializeWorkingDir(self):
463        """
464        Assuming the ``basedir`` directory contains a working copy ``module``
465        extracted from some VC repository, add it and all its content
466        to the target repository.
467
468        This implementation recursively add every file in the subtree.
469        Subclasses should override this method doing whatever is
470        appropriate for the backend.
471        """
472
473        self._addSubtree('.')
474
475    def _tag(self, tagname):
476        """
477        Tag the current version, if the VC type supports it, otherwise
478        do nothing.
479        """
480        pass
Note: See TracBrowser for help on using the repository browser.